Expertus metuit
Git в реальной жизни 2025
Опубликовано 2025-02-26 в 20:53

В статье рассказывается, как программисту пользоваться git. Текст основан на многолетнем опыте внедрения git и консультаций по нему. Основной фокус будет именно на практическом использовании и самых важных моментах. Я рекомендую читать статью последовательно, она писалась не как набор разрозненных рецептов, а именно как попытка объяснить, без описания всех возможных сценариев, команд и их аргументов.

Статья является переработкой моего же текста 2016 года Git в реальной жизни. За эти восемь лет git значительно изменился в лучшую сторону и стал (в его оригинальной консольной версии) гораздо понятнее.

Содержание

За много лет активного использования и внедрения git в компании я понял, что практически все обучающие статьи по этой теме бесполезны. Главная их проблема — они написаны для одиночек и почти полностью игнорируют аспекты командной работы именно технических специалистов. Технарям не нужно детально объяснять, как инициализировать новый репозиторий или как коммитить изменения. Эту информацию легко найти, но вот даже базового понимания, как работает git, она не даёт. К сожалению, в git очень много команд и очень много «непонятных» и «тупиковых» ситуаций, возникающих на ровном месте. Я специально пишу «непонятных» в кавычках, так как бо́льшая часть из них разруливается очень просто, нужно только понимать, что именно произошло.

Если вы хотите с разбега погрузиться во внутренности git, очень рекомендую статью Git from the inside out, в ней детально расписываются низкоуровневые механизмы работы репозитория.

В этой статье я подробно расскажу о нюансах работы с git, на которые вы практически гарантированно наткнётесь в реальной работе, однако в обычных туториалах о них не прочитаете. Также я расскажу о типичных ошибках, заблуждениях и вредных привычках. Некоторые вещи могут показаться банальными и очевидными, но поверьте, для кого-то они оказались неожиданными.

Также я старался все разделы статьи упорядочить по мере усложнения и расширения концепций. Все примеры кода должны воспроизводиться у каждого. Крайне желательно иметь linux/macos с терминалом под рукой. О совсем примитивных вещах типа установки git я тут писать не буду.

Весь текст полностью оригинален, не является переводом и базируется исключительно на официальной документации.

Все примеры написаны для версии git 2.48.1, последней на момент написания текста.

CLI vs. GUI

Я категорически не рекомендую начинать работу с git с GUI-клиента, это крайне вредная практика для новичков, поскольку GUI скрывает кучу нюансов и особенностей системы, которые можно увидеть только из стандартного клиента в командной строке. Переходить на GUI можно только после того, как вы достаточно освоите концепты git в консольной версии.

Консольная версия очень информативна и практически на любой команде в случае проблем выдаёт сразу несколько возможных вариантов их разрешения. Плюс справочные-страницы очень полезны, если нужно быстро узнать аргументы конкретной команды. Детальная справка доступна для каждой команды git с аргументом --help, например, git branch --help.

Язык и терминология

Нормальных устоявшихся русскоязычных эквивалентов терминологии git до сих пор нет. На официальном сайте git-scm.org есть только русскоязычный перевод книги Pro Git, который ещё и постоянно меняется.

Так что в этой статье я буду писать максимально понятным языком, на котором разговаривают программисты в обычной жизни, будут слова типа «вмержить» и «запушить», часто с англоязычными оригиналами в скобках.

Для ясности я постараюсь разделить понятия и действия, которые в английском оригинале обозначаются одним словом. К примеру, элемент истории изменений я буду называть коммитом, а действие по его созданию — записью изменений или записью коммита, или записью в репозиторий. Понятность для меня важнее формальной корректности.

Также основные термины, которые далее используются во всём тексте:

рабочая копия / рабочий каталог / рабочая директория / working directory
Это каталог, в котором находятся файлы вашего проекта, с которыми вы работает непосредственно, например, исходный код программы. В нашем случае — это всё содержимое каталога, кроме каталога с названием .git.
каталог репозитория
Это каталог с названием .git, в нём хранится в специально закодированном виде содержимое локального репозитория: конфигурация, внутренняя база объектов и так далее.
центральный / внешний / удалённый репозиторий
Это репозиторий, который вы склонировали себе командой git clone и получили локальный репозиторий. Часто это сетевой репозиторий, но не обязательно.

Название каталога с базой репозитория .git можно переопределить разными способами (например, через переменную окружения GIT_DIR), но я советую никогда этого не делать.

Миграция сознания с Subversion

Иногда люди приходят в git с солидным опытом в subversion (или даже CVS), к сожалению, практически никакой пользы от этого опыта не будет, всё равно придётся полностью переучиваться. Базовые концепты двух систем совершенно разные и ломка при переходе гарантирована.

В официальной документации превосходно описаны базовые концепты git, однако у пользователей по непонятной причине возникают серьёзные проблемы с их пониманием. Но текст этот крайне важен, поскольку там описывается абсолютно всё, что нужно для осознания сути git, буквально всё. Я эти ключевые концепты здесь перечислю:

Коммиты
История изменений репозитория организована как система связанных коммитов (commit). Один коммит — это один элемент истории версий репозитория. «Связанность» означает, что у коммита известен предок или предки (это тоже коммиты).
Снапшоты, а не патчи
Коммиты в git являются «снапшотами»/«снимками» состояния репозитория, а не наборами изменённых строк (patch) по сравнению с предыдущим коммитом, как в subversion, например. Ближайшей аналогией может послужить система снапшотов в VirtualBox/VMWare, когда сохраняется всё состояние виртуальной машины. Коммит содержит в себе все файлы, а не разницу с предыдущим коммитом.
Практически все операции локальны
В отличие от subversion, практически все операции над репозиторием происходят в локальном репозитории на машине разработчика. При этом локальный репозиторий не является точной копией центрального, хранящиеся в них данные могут отличаться. Всё общение с центральным репозиторием выделено в отдельные операции, которые выполняются независимо от собственно работы с данными в репозитории.
Git построен вокруг контроля целостности
Для всего содержимого считаются SHA1-чексуммы. Благодаря чексуммам обеспечивается связь между коммитами, а конкретная чексумма является уникальным идентификатором коммита. При этом в подсчёт чексуммы коммита включается чексумма его родительского коммита, поэтому все они образуют ориентированный (направленный) ациклический граф.
В git данные только добавляются
Практически все операции в git сводятся к добавлению новых данных. Другими словами, если вы всё делаете правильно, вы всегда можете исправить практически любую ошибку, в git сложно что-то потерять насовсем.
Три состояния файлов
В системах типа subversion в рабочей копии любой файл может находиться в двух состояниях: commited и modified. В git добавлено третье промежуточное состояние — staging (индексированное), о ней я расскажу ниже отдельно.

В переводе Pro Git (на текущий момент) слово commited переводится как зафиксированный, мне оно очень не нравится, поэтому я буду использовать слова типа записанное или сохранённое, а иногда для понятности даже закоммиченное (как говорят почти все программисты в реальной жизни).

Выбранная модель организации данных в git оказалась настолько успешной, что за все годы в ней было очень мало концептуальных изменений.

❈ ❈ ❈

Я буду неоднократно подчёркивать «снапшотную» сущность модели хранения изменений в git, поскольку это самый важный момент, от понимания которого полностью зависит полноценное и осознанное использование git. Если вы это осознаете, то абсолютно все подходы и вроде бы мистические команды станут понятными и логичными.

Инициализация репозитория

Практически любой туториал из интернета начинается с команды git init, однако в реальности её почти никто никогда не использует, все сразу начинают работу с уже существующим репозиторием на центральном сервере компании (обычно это github, gitlab, bitbucket). Так что я не буду слишком много внимания уделять первому появлению репозитория на вашей машине, просто склонируйте любой репозиторий с интернета и поиграйтесь с ним. Например, так:

$ git clone https://github.com/sigsergv/flask.git
Cloning into 'flask'...
remote: Enumerating objects: 25198, done.
remote: Total 25198 (delta 0), reused 0 (delta 0), pack-reused 25198 (from 1)
Receiving objects: 100% (25198/25198), 10.34 MiB | 8.71 MiB/s, done.
Resolving deltas: 100% (16923/16923), done.

Этот репозиторий я буду дальше в статье использовать для демонстрации функций git. Я специально «форкнул» настоящий живой репозиторий к себе в гитхаб, чтобы зафиксировать состояние на момент написания статьи. Вы можете смело повторять все примеры ниже, результат должен быть примерно таким же, как в тексте.

Также сразу нужно указать git своё имя и email, чтобы он мог их использовать при операциях (вы можете свои указать, а не мои), эту команду нужно запускать внутри рабочей копии репозитория (для нашего примера — в каталоге ./flask, куда вы склонировали репозиторий):

$ git config user.name "Sergey Stolyarov"
$ git config user.email "[email protected]"

Эти команды модифицируют только текущий репозиторий, данные записываются в файл .git/config

Также вы можете эти настройки выставить глобально, для всех репозиториев на машине:

$ git config --global user.name "Sergey Stolyarov"
$ git config --global user.email "[email protected]"

Эти команды модифицируют глобальный файл конфигурации, для unix-подобных систем это ~/.gitconfig

Зависимость от настроек

Поведение git чрезвычайно сильно зависит от настроек как локального репозитория, так и внешних. В данной статье я предполагаю, что у вас стандартные настройки по умолчанию и все примеры рассчитаны именно на это. Если в какой-то из команд есть сильная зависимость от конфига, я постараюсь этот момент отдельно прояснить.

Если вы будете бездумно копировать из интернета команды по конфигурации git, то неизбежно сломаете его поведение. Это особенно неприятно, если вы не представляете, как именно устроен git и что конкретно вы сломали. Если уж что-то совсем идёт не так, советую вообще удалить глобальный конфигурационный файл, в линуксе и макоси это делает команда rm ~/.gitconfig

Структура локального репозитория

Локальный git-репозиторий представляет собой каталог, в котором находятся файлы рабочей копии и специальный каталог .git, в котором хранится собственно репозиторий: файлы, каталоги, коммиты, ветки и так далее.

./flask
  ├─ .git/
  ├─ src/
  ├─ tests/
  ├─ README.md
  ├─ LICENSE.txt
  ...

Программа git понимает, что вы находитесь в репозитории, независимо от уровня вложенности. Другими словами, вы можете погрузиться на несколько уровней (каталогов) вглубь рабочей копии и программа всё равно будет знать, что вы внутри этого репозитория.

Не надо лазить ручками в файлы внутри каталога .git, это чревато непредсказуемыми проблемами, максимум, что можно себе позволить — это аккуратно редактировать конфиг в файле .git/config. Хотя даже для этого лучше пользоваться командой git config.

Итак, запомните: в каталоге .git лежит репозиторий, а всё что вне его — это рабочая копия. Все команды git в итоге производят какие-то операции над файлами внутри каталога .git, однако вам не нужно знать детально, что именно там происходит.

Состояния файла в репозитории и рабочей копии

Всего есть четыре возможных состояния файла в рабочей копии:

  • untracked — неотслеживаемый файл, он есть в каталоге рабочей копии, но не находится по контролем git, в выводе команды git status они показываются в секции Untracked files
  • unmodified — файл находится под котролем git, но его версия в рабочей копии совпадает с записанной в репозитории для текущего активного коммита
  • modified — модифицированный файл, он находится под котролем git, но в нём есть изменения по сравнению с записанным в репозитории состоянием; изменениями считаются как внутри содержимого, так и полное отсутствие файла в каталоге (например, если вы просто удалите файл из каталога рабочей копии, он пометится как модифицированный); в выводе команды git status такие файлы показываются в сеции Changes not staged for commit
  • staged — подготовленный/проиндексированный файл, он находится под контролем git, а внесённые в него изменения записаны в специальную область и готовы для коммита; в выводе команды git status индексированные файлы показываются в секции Changes to be committed.

Про игнорируемые файлы и .gitignore ниже будет отдельный раздел

Staged — это особое состояние файла из репозитория, ранее в переводе Pro Git его называли подготовленным, а теперь называют индексированным. По сути все staged-файлы являются предварительной заготовкой для коммита (и образуют staging area, индексированную область, кандидат для снимка/снапшота рабочей области), если вы наберёте команду git commit -m "Commit message" то будет создан коммит на основе текущей staging area. Файл становится «проиндексированным» после команды git add <FILENAME>, где <FILENAME> — имя модифицированного файла.

Между статусами файлы переводятся различными командами или внешними воздействиями (редактированием, удалением), вот примерная диаграмма состояний и способов перехода между ними:

merge initial state

Обратите внимание, что на диаграмме нет действия, которое переводит файл из состояния staged в состояние modified командой редактирования. Это потому, что после индексирования файла вы можете его редактировать, однако состояние в индексе не поменяется. Файл одновременно может находиться сразу в нескольких состояниях, именно поэтому в git status в названиях секций стоит не слово file, а слово change. Фактически там показываются именно изменения файла (факт удаления, изменённый фрагмент и т.д.), а не сам файл.

В чём преимущества и особенности staging area:

  • вы можете выбрать для коммита только нужные изменения, например, вы можете взять только определённые файлы или же часть изменений из одного файла;
  • частично следствие предыдущего пункта, вы можете из одного набора изменений сделать несколько коммитов, например, для каждой логической подсистемы в коде отдельный коммит.

Команда git commit <FILE> сразу записывает указанный файл <FILE> (или каталог), минуя стадию индексации. Если вы хотите закоммитить вообще все изменённые файлы из рабочей копии, используйте команду git commit -a

Вот небольшой пример, как это работает. Возьмём репозиторий flask, который мы ранее клонировали. Дальше листинг выполненных команд:

$ echo 'change 1' >> README.md

$ git add README.md

$ echo 'change 2' >> README.md

$ git status
On branch main
Your branch is up to date with 'origin/main'.

Changes to be committed:
  (use "git restore --staged <file>..." to unstage)
        modified:   README.md

Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git restore <file>..." to discard changes in working directory)
        modified:   README.md

Мы сделали тут следующее:

  • дописали строчку change 1 в файл README.md
  • проиндексировали это изменение
  • дописали строчку change 2 в файл README.md

Из статуса видно, что файл README.md содержит изменения как в staging area, так и вне её.

Если набрать команду git commit -m 'message', то будут записаны только изменения, помеченные как staged:

$ git commit -m 'Commmit message bla-bla-bla'
[main 6cc07a7d] Commmit message bla-bla-bla
 1 file changed, 1 insertion(+)

$ git status
On branch main
Your branch is ahead of 'origin/main' by 1 commit.
  (use "git push" to publish your local commits)

Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git restore <file>..." to discard changes in working directory)
        modified:   README.md

no changes added to commit (use "git add" and/or "git commit -a")

Для следующего примера снaчала восстановим состояние рабочей копии до исходного состояния (эта команда восстанавливает состояние всего текущего каталога, включая проиндексированные изменения):

$ git restore --staged --worktree .

❈ ❈ ❈

Если выполнить команду git commit . (точка в конце означает текущий каталог), то будут записаны вообще все изменения (включая индексированные), начиная с текущего каталога:

$ echo 'change 1' >> README.md

$ git add README.md

$ echo 'change 2' >> README.md

$ git commit -m 'Commit everything' .
[main e1650174] Commit everything
 1 file changed, 2 insertions(+)

$ git status
On branch main
Your branch is ahead of 'origin/main' by 1 commit.
  (use "git push" to publish your local commits)

nothing to commit, working tree clean

В общем случае я не советую использовать аргумент -m при коммите, гораздо безопаснее редактировать коммит-сообщение в редакторе, который откроется после ввода команды, так как там показывается, какие именно файлы будут записаны и можно сразу отловить много ошибок. Вот, например, как может выглядеть шаблон commit message в случае запуска git commit:

# Please enter the commit message for your changes. Lines starting
# with '#' will be ignored, and an empty message aborts the commit.
#
# On branch main
# Your branch is up to date with 'origin/main'.
#
# Changes to be committed:
#       modified:   README.md
#
# Changes not staged for commit:
#       modified:   README.md
#

Здесь видно, что будут записаны только изменения из staging area.

А вот текст шаблона сообщения для команды git commit .:

# Please enter the commit message for your changes. Lines starting
# with '#' will be ignored, and an empty message aborts the commit.
#
# On branch main
# Your branch is up to date with 'origin/main'.
#
# Changes to be committed:
#       modified:   README.md
#

Команда git commit FILE запишет в репозиторий все изменения файла FILE, включая индексированные.

Команда git commit -a коммитит вообще все изменённые файлы по всему репозиторию, включая staging area.

Простой принцип: сразу после изменения файл становится «изменённым» (modified), после команды git add <FILENAME> файл становится «проиндексированным» (staged).

Ну и финальное замечание на всякий случай: коммит является локальной операцией, она выполняется на локальном репозитории, а не на внешнем!

Пользуйтесь командой git status

Старайтесь почаще запускать команду git status, чтобы просмотреть текущее состояние рабочей копии локального репозитория. Помимо состояния эта команда также показывает возможные команды, которые вы захотите выполнить дальше.

Заведите себе привычку запускать git status перед любой другой командой.

Вот пример:

$ rm LICENSE.txt
$ rm pyproject.toml
$ echo 123 >> README.md
$ git status
On branch main
Your branch is ahead of 'origin/main' by 1 commit.
  (use "git push" to publish your local commits)

Changes not staged for commit:
  (use "git add/rm <file>..." to update what will be committed)
  (use "git restore <file>..." to discard changes in working directory)
        deleted:    LICENSE.txt
        modified:   README.md
        deleted:    pyproject.toml

no changes added to commit (use "git add" and/or "git commit -a")

Сначала мы вносим изменения в файлы, потом запускаем git status. В выводе мы видим следующее:

On branch main
Название ветки, в которой мы находимся: main
Your branch is up-to-date with 'origin/main'.
Состояние рабочей копии по сравнению с запомненным локально состоянием центрального (внешнего) репозитория. Фразу «запомненным локально» я специально выделил, так как команда git status сетевого запроса не делает. Дальше станет понятнее, когда я подробнее расскажу о принципах организации данных в git.
Changes not staged for commit:
Дальше идёт список изменённых файлов и возможные команды для этих файлов с краткими пояснениями. По каждому изменённому файлу указывается, каким именно образом он был изменён, например, modified или deleted.

В примере выше мы удалили файл LICENSE.txt. Наверное, зря и хотим его восстановить. Из текста статуса видно, что мы можем отменить изменения командой git restore, попробуем восстановить LICENSE.txt:

$ git restore LICENSE.txt

Как и многие unix-style команды, git в случае успешного выполнения ничего на экране не показывает, а если происходит ошибка, то показывает.

Посмотрим опять статус и убедимся, что файл LICENSE.txt исчез из списка изменённых и снова появился в каталоге:

$ git status
On branch main
Your branch is ahead of 'origin/main' by 1 commit.
  (use "git push" to publish your local commits)

Changes not staged for commit:
  (use "git add/rm <file>..." to update what will be committed)
  (use "git restore <file>..." to discard changes in working directory)
        modified:   README.md
        deleted:    pyproject.toml

no changes added to commit (use "git add" and/or "git commit -a")

$ ls -l LICENSE.txt
-rw-r--r-- 1 sigsergv sigsergv 1475 фев 28 12:48 LICENSE.txt

$

Ранее в git вместо команды git restore предлагалось использовать git checkout. И это было большой проблемой, поскольку в разных контекстах она работала совершенно по-разному и легко могла сломать рабочую копию. По этой причине различные функции git checkout были вынесены в отдельные команды, одной и которых стала как раз git restore. Далее я не буду указывать устаревшие команды, а буду писать только новые рекомендуемые.

❈ ❈ ❈

Теперь добавим наши изменения в файле README.md в staging area, сделаем их «индексированными». Обратите внимание, что я говорю «изменения», а не файл, мы можем добавить не весь файл, а только его часть! Но сейчас добавим весь и сразу посмотрим новый статус:

$ git add README.md

$ git status
On branch main
Your branch is ahead of 'origin/main' by 1 commit.
  (use "git push" to publish your local commits)

Changes to be committed:
  (use "git restore --staged <file>..." to unstage)
        modified:   README.md

Changes not staged for commit:
  (use "git add/rm <file>..." to update what will be committed)
  (use "git restore <file>..." to discard changes in working directory)
        deleted:    pyproject.toml

Видим, что теперь в статусе появилась секция со staged файлами, то есть проиндексированными для коммита, это секция Changes to be committed; для каждого файла показывается тип изменений (в данном случае modified — модифицированный) и возможные команды, в нашем случае это всего одна команда для операции unstage, которая возвращает файл в изменённое состояние из staged. Операция делается командой git restore --staged README.md

Unstage может понадобиться, чтобы, например, убрать из проиндексированных изменений лишние, добавленные по ошибке. Операция unstage не вносит никаких модификаций в файлы рабочей копии, она лишь снимает с них пометку «проиндексированности». Все внесённые изменения остаются, только они теперь не помечаются как staged.

Продолжим эксперимент и ещё раз модифицируем файл README.md, добавим в конец строчку ABC и посмотрим статус:

$ echo ABC >> README.md
$ git status
On branch main
Your branch is ahead of 'origin/main' by 1 commit.
  (use "git push" to publish your local commits)

Changes to be committed:
  (use "git restore --staged <file>..." to unstage)
        modified:   README.md

Changes not staged for commit:
  (use "git add/rm <file>..." to update what will be committed)
  (use "git restore <file>..." to discard changes in working directory)
        modified:   README.md
        deleted:    pyproject.toml

Мы видим, что теперь файл README.md сразу в двух секциях присутствует. Часть изменений проиндексирована, а часть ещё нет. И у нас есть два пути: добавить новые изменения в индекс командой git add README.md или убрать из индексированных изменений в просто изменения. Пойдём по второму пути:

$ git restore --staged README.md

$ git status
On branch main
Your branch is ahead of 'origin/main' by 1 commit.
  (use "git push" to publish your local commits)

Changes not staged for commit:
  (use "git add/rm <file>..." to update what will be committed)
  (use "git restore <file>..." to discard changes in working directory)
        modified:   README.md
        deleted:    pyproject.toml

no changes added to commit (use "git add" and/or "git commit -a")

Если откроем файл README.md, то увидим, что в нём в конце записаны все наши данные (строки 123 и ABC).

❈ ❈ ❈

Допустим в этот момент мы передумали и решили отменить вообще все сделанные только что изменения, для этого тоже используем git restore:

$ git restore README.md pyproject.toml

$ git status
On branch main
Your branch is ahead of 'origin/main' by 1 commit.
  (use "git push" to publish your local commits)

nothing to commit, working tree clean

$ ls -l README.md pyproject.toml
-rw-r--r-- 1 sigsergv sigsergv 2703 фев 28 12:52 pyproject.toml
-rw-r--r-- 1 sigsergv sigsergv 1504 фев 28 12:52 README.md

Как видим, изменения в README.md откатились, а файл pyproject.toml восстановлен.

Пользуйтесь gitk

gitk — это браузер статуса локального репозитория, мощная программа, которая очень поможет вам визуально оценить историю изменений и состояние репозитория. В debian/ubuntu gitk идёт отдельным одноимённым пакетом и ставится командой sudo apt install gitk. В виндовой версии входит в состав дистрибутива git. В macos можно поставить через brew, gitk входит в состав git из brew.

Запускать gitk нужно из каталога репозитория, всё поведение программы определяется аргументами при запуске. Запуск без аргументов открывает браузер для текущего состояния репозитория (текущая ветка или коммит, подготовленные файлы, модифицированные файлы).

Запуск с аргументом --all открывает дерево со всеми тегами, ветками и т.п.

a

В верхней части показываются коммиты плюс изменения, плюс индексированные изменения файл, два последних как «псевдокоммиты». Вы можете выбрать нужный коммит и тогда в нижней части будет показано его содержимое. gitk не является «графическим интерфейсом», через него нельзя выполнять команды, это всего лишь удобный браузер изменений.

Коммиты

Коммит является центральной сущностью git, и для его полного понимания нужно знать о внутреннем устройстве репозитория.

Как я уже говорил выше, коммит по сути является снапшотом («снимком состояния») всех файлов рабочей копии репозитория. Когда вы записываете изменения командой git commit, система создаёт новый снапшот. Это не набор изменений (patch/diff), а именно полный снапшот со всеми файлами. Git очень эффективно хранит коммиты, так что итоговый размер получается сравнительно небольшим.

Коммит состоит из следующих компонентов:

  • набор файлов — это просто все файлы из снапшота в виде блобов (от английского blob — binary large object), в каждом блобе содержимое одного файла, блоб идентифицируется своим SHA1-хешем; это только содержимое, без пути к файлу и без имени файла;
  • дерево файлов — это древовидная структура, описывающая положение файлов в каталоге файловой системы, каждый «файл» — это его имя с путём, а также SHA1-сумма/идентификатор соответствующего блоба; также внутри дерева файлов может стоять идентификатор другого дерева файлов — так целиком строится образ каталога со всеми вложенными файлами;
  • метаданные — это набор параметров коммита, например, сообщение, автор, дата и так далее;
  • идентификатор родительского коммита (или несколько родительских коммитов).

От всего этого набора данных считается SHA1-сумма, которая становится идентификатором коммита.

Если вы модифицируете какой-нибудь файл и записываете его в репозиторий в новом коммите, то для него создаётся новый блоб с новым SHA1-хэшем. В хранилище репозитория все блобы сохраняются в очень компактном виде.

Все коммиты образуют направленный ациклический граф, направление идёт от «дочернего» к «родительскому» коммиту. Другими словами, каждый коммит знает своего родителя, но родители не знают своих детей.

Это очень важный момент! Вы должны запомнить, что коммиты-снапшоты образуют направленный ациклический граф и что любой коммит зависит от всех своих предков по цепочке зависимостей. Представление репозитория как дерева/графа вам очень поможет в будущем для понимания сути операций merge и rebase.

Самый первый коммит в репозитории не имеет родительского, в нашем образцовом репозитории идентификатор самого первого коммита — 33850c0ebd23ae615e6823993d441f46d80b1ff0. Вот как можно посмотреть его содержимое как низкоуровневого объекта:

$ git cat-file -p 33850c0ebd23ae615e6823993d441f46d80b1ff0
tree 39953c10a179c7b97eaec74bdaa50d7885eb1366
author Armin Ronacher <[email protected]> 1270552377 +0200
committer Armin Ronacher <[email protected]> 1270552377 +0200

Initial checkin of stuff that exists so far.

Здесь мы видим идентификатор объекта «дерево-файлов» — 33850c0ebd23ae615e6823993d441f46d80b1ff0, автора (author) с указанием имени «Armin Ronacher», е-мейла «[email protected]» и времени «1270552377 +0200», коммитера (commiter) в таком же формате и текст комментария к коммиту.

Автор и коммитер могут быть разными. Условно автор — это кто изменения сделал, а коммитер — кто их записал в репозиторий.

В принципе, вам не нужно набирать весь идентификатор целиком. Во всех случаях, где требуется указать SHA1-идентификатор объекта, вы можете использовать только первые его символы, git дальше сам найдёт полный идентификатор, который начинается с указанной строки. Обычно используются только первые 7 символов идентификатора. Вот пример, как это работает:

git cat-file -p 33850c0
tree 39953c10a179c7b97eaec74bdaa50d7885eb1366
author Armin Ronacher <[email protected]> 1270552377 +0200
committer Armin Ronacher <[email protected]> 1270552377 +0200

Initial checkin of stuff that exists so far.

Однако если вы укажете совсем мало символов идентификатора, git будет ругаться (и заодно покажет элементы, которые подходят под эту строку):

$ git cat-file -p 8aaf
error: short object ID 8aaf is ambiguous
hint: The candidates are:
hint:   8aaf3025 commit 2013-05-30 - Disable direct passthrough for accessing the data attribute on newer Werkzeugs
hint:   8aaf1248 tree
hint:   8aaf5285 blob
fatal: Not a valid object name 8aaf

Идентификатор дерева файлов указывает на объект, который мы можем точно так же посмотреть:

$ git cat-file -p 39953c10a179c7b97eaec74bdaa50d7885eb1366
100644 blob 2149b775a109f2330c24c35fa2408c31f2720a09    .gitignore
040000 tree a3beb6ad8ed5cf4fcfa7cbb0be6221ae7858e2c9    examples
100644 blob 83d8a87e6ee93eab3055c17aacbd33e124ef7e91    flask.py

Каждая строчка описывает либо файл (blob), либо вложенное дерево (tree, по сути — подкаталог).

Команда git cat-file -p позволяет посмотреть отформатированное представление внутреннего объекта репозитория. Его тип определяется автоматически, всего их три: commit, tree и blob.

Идентификатор второго коммита в нашем репозитории — b15ad394279fc3b7f998fa56857f334a7c0156f6 (или сокращённо b15ad39), вот информация по нему:

$ git cat-file -p b15ad39
tree e918730b182b306a117f0130561f68f0e0d0a1b5
parent 33850c0ebd23ae615e6823993d441f46d80b1ff0
author Armin Ronacher <[email protected]> 1270552998 +0200
committer Armin Ronacher <[email protected]> 1270552998 +0200

Added setup.py and README

Видим, что добавился дополнительный идентификатор родительского (parent) коммита 33850c0ebd23ae615e6823993d441f46d80b1ff0.

Практически у всех коммитов в репозитории будет указан как минимум один родитель, исключение — первый коммит, из которого растёт всё дерево остальных коммитов. Таких «первых коммитов» в репозитории может быть несколько, но обычно он всё-таки один.

От любого коммита всегда можно отстроить дерево его родительских коммитов.

❈ ❈ ❈

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

Целостность обеспечивается надеждой, что не случится коллизии SHA1-сумм объектов. Теоретически возможно изменить коммит так, чтобы его SHA1-сумма не изменилась. Однако в реальности это практически невозможно.

Таким образом, git-репозиторий представляет собой очень простое key-value хранилище объектов. В роли объектов выступают блобы, деревья файлов и коммиты. У каждого объекта есть идентификатор — его SHA1-сумма. Низкоуровневая команда git cat-file -p <object id> позволяет просмотреть содержимое объекта любого типа по его идентификатору.

Все высокоуровневые команды git в итоге сводятся к командам по управлению низкоуровневыми объектами нескольких типов, подробнее об этом можно почитать в официальной книге, в разделе Git изнутри - Объекты в Git.

❈ ❈ ❈

А где же каталоги? Информация о каталогах хранится в опосредованном виде, каталог — это объект репозитория типа tree. По этой причине в git нельзя закоммитить пустой каталог, так как соответствующий ему объект типа tree будет иметь одинаковую SHA1-чексумму. Так что если вам нужен каталог в репозитории, обязательно положите туда какой-нибудь файл и запишите в репозиторий.

❈ ❈ ❈

Для git все коммиты фактически равноправны, независимо от того, откуда они пришли — созданы локально или вытащены из сетевого репозитория. Процесс обновления локального репозитория сводится к простому скачиванию объектов (commit, tree или blob) с другого репозитория. Аналогично выглядит обновление сетевого репозитория — объекты просто передаются туда.

❈ ❈ ❈

Но у модели данных git есть и существенный недостаток — она плохо управляется с бинарными файлами. Коммит новой версии файла (например, jpg-картинки) добавляет в репозиторий существенный кусок данных, при этом все остальные остаются на месте и не удаляются. Хранить большие бинарные файлы и архивы в git-репозитории не очень рационально.

Оргпроцессы при работе с коммитами в репозитории

Часто можно встретить замечание, что в репозитории коммиты образуют дерево, в общем случае это не так. В репозитории цепочки коммитов разветвляются и сливаются снова. Крайне важно держать этот процесс под контролем и не допускать хаотичных операций, превращающих репозиторий в хаотичную кашу.

Существует несколько хорошо описанных процессов ведения репозитория при совместной работе, их можно нагуглить по словам git workflows. Вы можете придумать и собственный, главное, чтобы он был хорошо описан и документирован.

Также в качестве центрального внешнего репозитория лучше всего держать сервер типа github/gitlab, в котором помимо собственно репозитория есть множество других инструментов для коллективной работы и поддержки процессов разработки.

Работа с изменениями в рабочей копии

Самый типичный сценарий работы с файлами в рамках текущей ветки: просто редактируем, добавляем, удаляем. Затем перед коммитом запускаем git status, внимательно его изучаем и делаем всё, чтобы все изменения попали в коммит.

Звучит просто, но часто люди допускают одни и те же ошибки в этом процессе:

Не индексируют для коммита новые файлы или каталоги
Особенно часто это происходит, когда новые файлы просто теряются в списке других файлов, которые коммитить не нужно. Чтобы это минимизировать, добавляйте гарантированно неподходящие для коммита файлы в .gitignore. Команда git commit -a не добавленные под контроль git файлы не коммитит.
Забывают добавить в индекс измененения
Это часто происходит, когда изменения записываются командой git commit без аргументов, она создаёт новый коммит на основании только содержимого индексной области и просто модифицированные файлы в коммит не попадают.

Заведите привычку сразу (сразу! до git push!) после коммита запускать команду git status, чтобы оценить состояние репозитория. Если вы там увидите пропущенные файлы, то сможете их добавить к самому последнему коммиту через команду git commit --amend — она как раз позволяет модифицировать последний коммит, без дополнительных аргументов она дописывает в последний коммит изменения из индексной (staging) области, но можно добавить аргумент -a, тогда будут добавлены все модифицированные файлы, включая индексированные (git commit -a --amend).

Вы можете произвольно менять коммиты до того, как они уехали в центральный репозиторий.

Чаще всего команда git commit -a позволяет избежать многих проблем, она записывает в репозиторий все изменения из индексной области и все просто модифицированные файлы (удаление файла, находящегося под контролем git, тоже рассматривается как модификация). В случае её использования можно даже не добавлять файлы в индекс, за исключением новых.

Индексная область оказывается особо удобной, когда надо из одного набора изменений сделать несколько коммитов. К примеру, если в одном репозитории находится фронтенд и бэкенд, изменения в каждом можно занести в разные коммиты. В такой ситуации сначала индексируются изменения для коммита во фронтенд — через git add туда добавляются все изменённые js-файлы, далее вызывается команда git commit, которая записывает только проиндексированные изменения. Затем процесс повторяется с оставшимися модифицированными файлами.

Референсы и теги

Коммиты-снапшоты в git образуют ориентированный ациклический граф, эти коммиты никогда не меняются и мы можем любой из них пометить тегом (tag). Тег — это уникальная метка-строка, указывающая на конкретный коммит, по сути он является удобным коротким именем для длинного идентификатора коммита. Обычно тегами помечаются важные коммиты, например, коммит для конкретной версии продукта.

Посмотреть список всех тегов можно командой git tag:

$ git tag
0.1
0.10
0.10.1
0.11
0.11.1
...
3.0.3
3.1.0
$ 

Вы можете использовать тег везде, где требуется идентификатор коммита. Например, вы можете переключить рабочую копию на коммит конкретной версии командой типа git switch --detach tags/3.0.3.

Такое возможно потому, что git является набором снимков/снапшотов файлов в репозитории и вы всегда можете переключиться на любой из них по идентификатору коммита. Без аргумента --detach команда git switch не сможет переключить рабочую копию на тег, поскольку он не является веткой. Переключиться на тег (или произвольный коммит) можно только через отсоединённый (detached) режим. Подробнее о нём я расскажу ниже.

Описанные выше теги — это внешние сущности для внутренней базы объектов, они никак не фигурируют в зависимостях между коммитами и при желании тег можно удалить и «повесить» на другой коммит. В терминологии git такие теги называется облегчёнными (lightweight), помимо них также существуют аннотированные, в них кроме идентификатора коммита можно включить произвольный текст, дату и PGP-подпись. Про аннотированные теги я немного расскажу в разделе о подписывании объектов.

Тег является ссылкой (reference, ref), я в этой статье (и в других моих текстах про git) использую слово референс. Референс всегда «указывает» на какой-нибудь конкретный идентификатор коммита, а все референсы хранятся в каталоге репозитория .git отдельно от внутренней базы объектов в файле .git/packed-refs и каталоге .git/refs. .git/packed-refs — это простой текстовый файл, его содержимое выглядит примерно так:

# pack-refs with: peeled fully-peeled sorted 
f61172b8dd3f962d33f25c50b2f5405e90ceffa5 refs/remotes/origin/main
8605cc310d260c3b08160881b09da26c2cc95f8d refs/tags/0.1
3b9574fec988fca790ffe78b64ef30b22dd3386a refs/tags/0.10
298334fffc8288b5a9a45ef4150e3c4292e45318 refs/tags/0.10.1
13e6a01ac86f9b8c0cad692d5e5e8d600674fb6d refs/tags/0.11
... куча примерно таких же строчек
2efaec117637fa51c6c53588899002def7eee37a refs/tags/3.1.0
^ab8149664182b662453a563161aa89013c806dc9

Каждая строка состоит из идентификатора коммита и полного имени референса. Полное имя имеет иерархическую структуру, элементы иерархии разделяются символом «/», они могут быть внутренними категориями или именами. В листинге выше категории — это refs, remotes, tags; имена — origin, main, 0.10.1.

Полное имя прозрачно транслируется на файловую структуру в каталоге .git/refs, например, референсу refs/tags/2.0.0rc2 соответствует такой же путь внутри каталога .git, это путь до простого текстового файла, внутри которого идентификатор коммита. Этот файл существует, только если вы пользовались референсом. Если референс не использовался, он хранится в файле .git/packed-refs вместе с остальными. Посмотреть все референсы в текущем репозитории можно командой git show-ref

Ещё одним типом референса является ветка (branch). Ветки в git вызывают серьёзные затруднения у новичков. В subversion коммиты представляют собой наборы патчей, а ветка (branch) в терминологии SVN — это всего лишь отдельный каталог в общем дереве файлов. Ветка в git — это совершенно иная сущность, не имеющая ничего общего с ветками в subversion.

По сути ветка в git является динамическим тегом; меткой, которая указывает на конкретный коммит, но значение этой метки при каждом новом коммите автоматически сдвигается на новое. Как и тег, ветка является внешней сущностью для базы объектов и может указывать на любой коммит и никак не зависит от предыдущих коммитов в ветке.

В нашем образцовом репозитории веткой по умолчанию является main, её полное имя — refs/heads/main (да, здесь очередной пример неконсистентности внутренней структуры git, категория для веток в иерархии компонентов полного имени референса называется не branches, а heads). Посмотрим на последний коммит в ветке main:

$ git log -n1 main 
commit f61172b8dd3f962d33f25c50b2f5405e90ceffa5 (HEAD -> main, origin/main, origin/HEAD)
Merge: 60a11a73 959052fb
Author: David Lord <[email protected]>
Date:   Sun Jan 5 09:10:00 2025 -0800

    Merge branch 'stable'

А теперь посмотрим, что хранится в файле .git/refs/heads/main:

$ cat .git/refs/heads/main
f61172b8dd3f962d33f25c50b2f5405e90ceffa5

Там действительно лежит идентификатор нужного коммита — f61172b8dd3f962d33f25c50b2f5405e90ceffa5.

❈ ❈ ❈

Теперь посмотрим опять на команду git log -n1 main, в ней мы взяли один последний элемент из истории коммитов в ветке main, однако вместо main мы можем использовать любой другой референс или даже любой идентификатор коммита. git log показывает на самом деле цепочку коммитов, начиная с указанного, затем его родителя, родителя его родителя и так далее до самого первого коммита, у которого нет предка.

Когда вы передаёте в команду референс, скажем, REFNAME, git должен его превратить в конкретный идентификатор коммита, для этого он сначала пытается короткое имя преобразовать в полное, попутно определяя тип референса. Полностью этот процесс описан в документации gitrevisions, а упрощённо список проверямых полных референсов выглядит так (поиск идёт сверху вниз и прекращается при совпадении):

  • refs/REFNAME
  • refs/tags/REFNAME
  • refs/heads/REFNAME
  • refs/remotes/REFNAME

Обычно конфликтов между референсами не возникает, однако у вас в команде практически неизбежно кто-нибудь создаст, например, тег, совпадающий с названием ветки. Мы можем это сами проделать на нашем тестовом репозитории (создадим тег main, указывающий на коммит 926ab92118006c74a0a33bcb8f770522d007349a):

$ git tag main 926ab92118006c74a0a33bcb8f770522d007349a

git позволил это сделать и не выдал никаких сообщений об ошибках. Если мы теперь попробуем сделать git switch main, то увидим такое (если в локальном репозитории ещё нет ветки master):

$ git switch main
warning: refname 'main' is ambiguous.
Already on 'main'
Your branch is up to date with 'origin/main'.

Короче, старайтесь этого избегать.

❈ ❈ ❈

Вы можете удалить тег:

$ git tag -d main       
Deleted tag 'main' (was 926ab921)

В целом git в нужном контексте действует достаточно интеллектуально и преобразует референс в наиболее подходящий для данного контекста объект. Референсы используются очень активно и если вы поймёте базовые принципы их формирования, вы автоматически научитесь понимать кучу «магических» команд.

На один коммит может указывать несколько разных референсов. Например, в какой-то момент референс ветки и тега могут совпадать.

Внешний репозиторий

Git — распределённая система и ваш локальный репозиторий обычно склонирован из какого-то внешнего. Такой внешний репозиторий в местной терминологии называется remote. В официальной документации на русском языке внешние репозитории называются удалёнными, однако лично мне это слово очень не нравится, так как слишком многозначное.

Внешние репозитории идентифицируются в локальном через короткое имя. Каждому имени соответствует некоторый URL собственно репозитория. При клонировании сразу автоматически создаётся внешний репозиторий под названием origin.

Крайне важный момент: origin — это всего лишь имя для сущности remote, постарайтесь чётко разделить эти два понятия. remote — название класса объектов, origin — название элемента. Неформально можно считать remote репозиторием, а origin — ссылкой-названием этого репозитория в локальном репозитории.

Внешний репозитория не обязательно должен быть сетевым, вы вполне можете склонировать один локальный репозиторий в другой, просто указав вместо URL путь в файловой системе. Ранее мы склонировали репозиторий, скажем, в каталог /home/sigsergv/projects/flask, теперь его же можно использовать как источник при клонировании:

$ cd ~/tmp 

$ git clone /home/sigsergv/projects/flask flask-local-clone
Cloning into 'flask-local-clone'...
done.

Здесь мы создаём в каталоге ~/tmp репозиторий flask-local-clone, склонированный с другого локального репозитория /home/sigsergv/projects/flask.

❈ ❈ ❈

Для работы с внешними репозиториями используется команда git remote. Например, список всех внешних репозиториев можно посмотреть такой командой:

$ git remote
origin
$

Это компактная версия, которая возвращает только имена, посмотреть их URL можно так:

$ git remote -v
origin  https://github.com/sigsergv/flask.git (fetch)
origin  https://github.com/sigsergv/flask.git (push)
$

Обратите внимание, что git разделяет адреса внешних репозиториев для получения (fetch) и отправки (push) данных. Чаще всего они совпадают, но иногда требуется их разделить. Например, для fetch используется https, а для push — более безопасный ssh.

❈ ❈ ❈

Выше был типичный текст из туториала, из которого сложно понять, что такое remote на самом деле. На самом деле всё очень просто. Каждый репозиторий (в том числе и внешний сетевой) представляет собой набор связанных между собой объектов типа commit, tree и blob (о них я выше писал). Команда git fetch «вытаскивает» в локальный репозиторий объекты из внешнего, а git push наоборот, отправляет новые локальные объекты-коммиты во внешний репозиторий из локального.

Благодаря структуре внутренней базы git, эта операция исключительно простая и сводится к простому копированию объектов, у которых сохраняются все их идентификаторы и связи.

Вместо git fetch обычно используется git pull, эта команда сначала скачивает новые объекты, а потом пытается обновить текущую ветку и текущую рабочую копию путём merge (или rebase). Но я рекомендую пользоваться git pull с осторожностью.

Операция fetch — не деструктивная, её можно безопасно запускать практически в любое время, она не вносит никаких изменений в локальную рабочую копию. Часто можно прочитать, что в локальном репозитории находится копия внешнего, которая обновляется исключительно командой git fetch, в реальности это немного не так — в локальном репозитории (именно репозитории, не рабочей копии!) все коммиты абсолютно равноправны, они находятся в одном пространстве и локальные никак не отличаются от внешних. Все отличия исключительно в метках, которые развешаны на дереве коммитов.

Все референсы с внешнего репозитория лежат в специальном «пространстве имён». Полное имя референса на ветку main с внешнего репозитория с именем origin выглядит так: refs/remotes/origin/main, это совершенно обычный референс и на него можно спокойно переключить локальную рабочую копию (снова в отсоединённом режиме):

$ git switch --detach origin/main
HEAD is now at f61172b8 Merge branch 'stable'

% git status
HEAD detached at origin/main
nothing to commit, working tree clean

Я использую сокращённый референс origin/main, чтобы найти этот коммит, git последовательно проверит следующие полные референсы:

  • refs/origin/main
  • refs/tags/origin/main
  • refs/heads/origin/main
  • refs/remotes/origin/main

Из них найдётся последний: refs/remotes/origin/main, именно на него будет переключена рабочая копия.

Все референсы, полные имена которых начинаются на refs/remotes, образуют по сути образ внешнего репозитория внутри локального. Новые данные с внешнего репозитория вы не увидите, пока их не обновите явным образом командами git fetch или git pull.

Если вы видите референс вида remotes/AAA/BBB, то это референс на ветку BBB с внешнего репозитория по имени AAA.

Git не запрещает менять значения референсов, вы спокойно можете условно внешнему референсу назначить другой коммит. Вот пример:

$ git show-ref refs/remotes/origin/main
f61172b8dd3f962d33f25c50b2f5405e90ceffa5 refs/remotes/origin/main

$ git show-ref refs/tags/1.1.0
1b4ace9ba5e77679bf9d8e409283654f7589907e refs/tags/1.1.0

$ git update-ref refs/remotes/origin/main 1b4ace9ba5e77679bf9d8e409283654f7589907e

$ git show-ref refs/remotes/origin/main
1b4ace9ba5e77679bf9d8e409283654f7589907e refs/remotes/origin/main

$

В этом примере мы поменяли значения референса ветки origin/main на совершенно посторонний коммит 1b4ace9ba5e77679bf9d8e409283654f7589907e. Не делайте так никогда! Впрочем, git сам поддерживает целостность внешних референсов и при следующей операции git fetch сбросит ваши изменения:

$ git fetch
From https://github.com/sigsergv/flask
   1b4ace9b..f61172b8  main       -> origin/main

$ git show-ref refs/remotes/origin/main
f61172b8dd3f962d33f25c50b2f5405e90ceffa5 refs/remotes/origin/main

Видим, что значение референса вернулось в исходное значение.

❈ ❈ ❈

Вы можете одновременно работать с несколькими внешними репозиториями, более того, вы можете подключить совершенно посторонние, которые никак не пересекаются с уже добавленными! В качестве примера я сейчас добавлю в наш образцовый локальный репозиторий ссылку на сторонний репозиторий проекта flask-session и сразу же вытащу оттуда его содержимое:

$ git remote add flask-session https://github.com/pallets-eco/flask-session

$ git fetch flask-session
Receiving objects: 100% (1928/1928), 1.62 MiB | 3.10 MiB/s, done.
Resolving deltas: 100% (1076/1076), done.
From https://github.com/pallets-eco/flask-session
 * [new branch]        dependabot/pip/requirements/jinja2-3.1.5   -> flask-session/dependabot/pip/requirements/jinja2-3.1.5
 * [new branch]        dependabot/pip/requirements/werkzeug-3.0.6 -> flask-session/dependabot/pip/requirements/werkzeug-3.0.6
 * [new branch]        development                                -> flask-session/development
 * [new branch]        main                                       -> flask-session/main
 * [new branch]        patch-1                                    -> flask-session/patch-1
 * [new branch]        patch-test                                 -> flask-session/patch-test
 * [new tag]           0.2.1                                      -> 0.2.1
 * [new tag]           0.2.2                                      -> 0.2.2
...
 * [new tag]           0.8.0                                      -> 0.8.0

$

Теперь в списке внешних репозиториев появилась новая строчка flask-session:

$ git remote
flask-session
origin
$

И в списке всех внешних веток (показывается командой git branch -r) появились новые:

$ git branch -r
  flask-session/HEAD -> flask-session/development
  flask-session/dependabot/pip/requirements/jinja2-3.1.5
  flask-session/dependabot/pip/requirements/werkzeug-3.0.6
  flask-session/development
  flask-session/main
  flask-session/patch-1
  flask-session/patch-test
  origin/HEAD -> origin/main
  origin/main

Локальная ветка main (с которой мы всё это время работали) «растёт» из репозитория origin, если мы хотим поработать с этой же веткой, но в репозитории flask-session, мы должны эту ветку «вытащить» локально. Имя main уже занято, поэтому возьмём имя fs-main:

$ git switch -c fs-main flask-session/main
branch 'fs-main' set up to track 'flask-session/main'.
Switched to a new branch 'fs-main'

Команда git switch -c <NEW BRANCH> <START POINT> создаёт новую ветку с именем <NEW BRANCH>, которая заканчивается коммитом <START POINT>, а после создания переключает рабочую копию на эту новую ветку. Так как в нашем случае <START POINT> является референсом ветки во внешнем репозитории, git эту ситуацию распознаёт и для новой ветки автоматически дополнительно подключается трекинг (track) к внешней.

Фраза branch 'fs-main' set up to track 'flask-session/main'. означает, что наша новая ветка fs-main теперь связана (трекается) с веткой main внешнего репозитория flask-session. Это значит, что когда вы будете выполнять команду git pull, находясь в локальной ветке fs-main, git сначала скачает все новые объекты с сервера внешнего репозитория (его URL был прописан в конфиге, когда мы делали команду git remote add), а затем попытается обновить эту ветку новыми коммитами, которые появились в ветке flask-session/main с момента последнего предыдущего обновления.

Аналогично с командой push, но детальнее об этом и pull/fetch я расскажу позднее.

Когда вы работаете с веткой fs-main, ваша рабочая копия полностью меняется. По сути происходит полное перезаписывание всех файлов в рабочей копии.

Ветки

Ветка как референс

Когда вы только склонировали репозиторий, рабочая копия переключается на ветку по умолчанию. Название этой ветки сообщает центральный репозиторий, с которого вы делает клон. Если же он этого не делает, то git пытается найти ветку сам, но в детали этого процесса я вдаваться не буду.

Если вы выше склонировали мой демо-репозиторий, то он должен после локальной инициализации переключить рабочую копию на ветку main. Посмотреть локальные ветки можно командой git branch, текущая активная ветка помечается звёздочкой:

$ git branch
  fs-main
* main

Команда git branch без аргументов показывает только существующие локальные ветки. При этом в локальном склонированном репозитории хранится информация обо всех ветках с исходного репозитория, вы можете их посмотреть командой git branch --remote или сокращённо git branch -r:

$ git branch -r
  flask-session/HEAD -> flask-session/development
  flask-session/dependabot/pip/requirements/jinja2-3.1.5
  flask-session/dependabot/pip/requirements/werkzeug-3.0.6
  flask-session/development
  flask-session/main
  flask-session/patch-1
  flask-session/patch-test
  origin/HEAD -> origin/main
  origin/main

Создание локальной ветки

Выше был пример, показывающий, как создать локальную ветку и затрекать её с внешней, давайте сделаем то же самое для ветки patch-1: git switch -c patch-1 flask-session/patch-1 В данном случае название локальной и внешней ветки совпадает, поэтому можно написать просто:

$ git switch patch-1
branch 'patch-1' set up to track 'flask-session/patch-1'.
Switched to a new branch 'patch-1'

Git здесь по короткому названию ветки нашёл её в записях внешнего репозитория, создал локальную ветку с таким названием, выставил активный коммит как во внешнем репозитории и переключл рабочую копию на ветку. Также он включил трекинг изменений.

Однако если локальная ветка с указанным названием уже существует, команда git switch просто на неё переключит.

Вы можете создать новую ветку без переключения на неё командой git branch <NEW BRANCH>:

$ git branch xxx

При этом будет создана новая ветка, начинающаяся с вашего текущего коммита в рабочей копии. Можно указать другой стартовый коммит: git branch <NEW BRANCH> <START POINT>, здесь <START POINT> может быть любым референсом (коммитом, тегом, веткой и так далее).

Создавайте новые ветки, используя <START POINT> только из внешнего репозитория, и заранее делайте git fetch. То есть в качестве стартовой точки всегда указывайте что-то вроде origin/master.

Допустим, вы хотите начать новую фичу, используя в качестве стартовой точки актуальное состояние ветки master, самый правильный вариант такой:

$ git fetch
$ git branch --track new-branch origin/main
branch 'new-branch' set up to track 'origin/main'.

Здесь три важных момента. Первый — в качестве стартовой точки используем ветку origin/main с внешнего репозитория. Второй момент — перед ветвлением вызываем git fetch, чтобы актуализировать значение референса origin/master. И третий — используем аргумент --track, чтобы связать локальную ветку с внешней.

Часто люди для старта новой ветки используют команду git branch new-branch master, её проблема в том, что стартовая точка указывает на локальную ветку master. А если вы давно её не обновляли, master может указывать на очень старое состояние.

Новая ветка создана, однако рабочая копия по-прежнему находится на старом коммите. Не забывайте переключиться на новую ветку командой git switch new-branch

❈ ❈ ❈

В git ветки легковесные, операция создания новой ветки почти ничего не стоит и практически не увеличивает размер репозитория.

Ветка master или main

Исторически в git при инициализации создавалась ветка с названием master. Позднее по причинам политкорректности название master поменяли на main, и теперь в новом репозитории ветка по умолчанию называется так.

Нет никаких специальных требований называть главную ветку именно master или main, вы можете выбрать любое другое название, но весь мир уже привык к master и почти привык к main.

Удаление ветки не удаляет коммиты

Для удаления ветки используется команда git branch -d <BRANCH NAME>, удалить вы можете только неактивную ветку. По умолчанию git branch -d позволит удалить ветку с трекингом внешней ветки, только когда она полностью синхронизована с ней:

$ git branch -d fs-main
error: the branch 'fs-main' is not fully merged
hint: If you are sure you want to delete it, run 'git branch -D fs-main'
hint: Disable this message with "git config set advice.forceDeleteBranch false"

Это поведение можно отменить командой git branch -D <BRANCH NAME>, она всегда удаляет ветку.

Ветка по сути является внешней динамически изменяемой меткой, которая перемещается по графу репозитория при каждом новом коммите. Таким образом, удаление ветки всего лишь удаляет эту метку, не трогая сами коммиты.

Если вы случайно удалили ветку, то ваши коммиты до какого-то времени останутся в локальном репозитории, однако со временем сборщик мусора их удалит, если на них не будет ссылок со стороны тегов или других веток.

Переключение между ветками

Команда git switch <BRANCH NAME> переключает рабочую копию на локальную ветку (если она существует) или пытается сначала создать новую локальную ветку, если существует такая же на внешнем репозитории.

Я настоятельно советую никогда не пользоваться командой git checkout для переключения веток, так как вы можете получить совсем не тот результат, какой ожидаете. Пользуйтесь только git switch, так гораздо меньше вероятность что-то сломать.

Если при переключении у вас есть модифицированные файлы, git попытается эти изменения перетащить в новое состояние рабочей копии.

Чрезвычайно полезная команда git switch - переключает рабочую копию на предыдущее состояние (ветку или коммит). Она работает по аналогии с shell-командой cd -

Отсоединённый режим

Отсоединённый или detached режим используется, если вам нужно поисследовать состояние рабочей копии на определённом коммите. В этом режиме все новые коммиты будут фактически потеряны, поскольку рабочая копия «отцеплена» от любой ветки.

Для перехода в отсоединённый режим нужно использовать команду git switch --detach REFNAME, где в качестве REFNAME может выступать любой референс (тег, ID коммита, ветка и так далее).

Специальный референс: HEAD

HEAD — это специальный референс, содержащий идентификатор коммита, на который в данный момент переключена рабочая копия. При каждом переключении его значение меняется, также он обновляется при коммите.

Фраза detached HEAD означает, что в рабочей копии репозитория находится сейчас отдельный коммит, а не ветка. Если вы сделаете какие-либо изменения и закоммитите их, то получите «потерянный» коммит, не привязанный ни к какой ветке, вы можете легко его потерять, у вас нет простого способа на него снова переключиться и так далее.

Референс HEAD удобно использовать везде, где нужно ссылаться на текущий актуальный коммит в рабочей копии.

Физически значение HEAD хранится в файле .git/HEAD.

Вместо HEAD можно везде писать @.

Внешние ветки (remote-tracking branch)

Выше я уже упоминал о внешних ветках, их референсы выглядят так: <REMOTE>/<BRANCH NAME>, типичный пример: origin/master. Внешние ветки по сути отображают внешний репозиторий в вашем локальном. То есть ветка origin/master после каждой команды git fetch обновляет своё значение на актуальный идентификатор ветки master на внешнем репозитории. Аналогично происходит со всеми остальными внешними ветками.

Посмотреть список всех внешних веток со всех внешних репозиториев можно командой git branch -r:

$ git branch -r
  flask-session/HEAD -> flask-session/development
  flask-session/dependabot/pip/requirements/jinja2-3.1.5
  flask-session/dependabot/pip/requirements/werkzeug-3.0.6
  flask-session/development
  flask-session/main
  flask-session/patch-1
  flask-session/patch-test
  origin/HEAD -> origin/main
  origin/main

В списке есть две специальных записи:

  flask-session/HEAD -> flask-session/development
  origin/HEAD -> origin/main

Они означают, что у соответствующих внешних репозиторев назначены указанные ветки ветками по умолчанию, именно на них будет переключена рабочая копия после клонирования.

Внешние ветки всегда обновляются с внешнего репозитория командами git fetch или git pull, чтобы ваш локальный репозиторий всегда имел актуальные значения для внешних веток, почаще запускайте команду git fetch.

Апстрим-ветки (upstream branches)

Стандартный процесс работы команды с единым внешним репозиторием предполагает, что у каждого разработчика есть локальный репозиторий, склонированный с центрального. Периодически каждый разработчик скачивает с центрального репозитория новые коммиты и закачивает туда свои. Таким образом поддерживаться синхронизованность локальных репозиториев.

Такой обмен коммитами происходит через так называемые апстрим-ветки (upstream branches) — внешние ветки в локальном репозитории, через которые локальные ветки трекают (track) состояние. Когда вы только склонировали репозиторий, первая ветка по умолчанию автоматически включает трекинг с веткой во внешнем репозитории с таким же названием — апстрим-веткой. Далее при операции git pull новые коммиты из апстрим-ветки будут интегрированы в локальную ветку, а про операции git push новые изменения из локальной ветки будут отправлены во внешний репозиторий в апстрим-ветку. Локальные ветки, для которых назначен трекинг с апстрим-веткой, называют отслеживаемыми ветками (tracking branches).

Вы можете посмотреть, какие из локальных веток имеют апстрим-ветки командой git branch -vv:

$ git branch -vv
  development bc2fe679 [flask-session/development] DynamoDB: Add table_exists parameter and extend documentation (#237)
* main        f61172b8 [origin/main] Merge branch 'stable'
  patch-1     812b9cc2 [flask-session/patch-1] added back isolated_build

В данном листинге у всех веток назначены апстрим-ветки для трекинга (они указаны в квадратных скобках), однако ветки могут быть и без них. Например, простое создание ветки не назначает апстрим ветку для трекинга:

$ git branch test-branch main

$ git branch -vv
  development bc2fe679 [flask-session/development] DynamoDB: Add table_exists parameter and extend documentation (#237)
* main        f61172b8 [origin/main] Merge branch 'stable'
  patch-1     812b9cc2 [flask-session/patch-1] added back isolated_build
  test-branch f61172b8 Merge branch 'stable'

Видим, что у ветки test-branch трекинг не назначен. При создании ветки можно указать аргумент --track и тогда для новой локальной ветки будет назначена апстрим-веткой та, с которой она была создана:

$ git branch test-branch-2 --track origin/main

Здесь нужно быть очень аккуратными, так как вы обязательно должны указать название внешней ветки с указанием репозитория-remote, если указать вместо origin/main просто main, git никак не предупредит, а просто сделает совершенно бесполезный трекинг одной локальной ветки на другую.

Также вы можете создать локальную ветку с трекингом и сразу переключиться на неё одной командой:

$ git switch -c test-branch-2 --track origin/main
branch 'test-branch-2' set up to track 'origin/main'.
Switched to a new branch 'test-branch-2'

❈ ❈ ❈

Чтобы продемонстрировать принцип работы апстрим- и отслеживаемых веток, сделаем специальную ветку local-main, которая отстаёт от внешней ветки на несколько коммитов:

$ git switch -c local-main d5b7a05ab2a053c617149cfd4f7e3379bfd279e5

Эта ветка базируется на коммите d5b7a05ab2a053c617149cfd4f7e3379bfd279e5 из ветки main, однако сам git пока ничего не знает о связи новой ветки local-main и каких-то других веток (мы не указали аргумент --track при создании ветки):

$ git status
On branch local-main
nothing to commit, working tree clean

К счастью, можно апстрим-ветку назначить позднее, это делается командой git branch --set-upstream-to <REMOTE BRANCH> или в сокращённой форме git branch -u <REMOTE BRANCH>, назначим для ветки local-main апстрим-веткой origin/main:

$ git branch -u origin/main
branch 'local-main' set up to track 'origin/main'.

$ git status
On branch local-main
Your branch is behind 'origin/main' by 12 commits, and can be fast-forwarded.
  (use "git pull" to update your local branch)

nothing to commit, working tree clean

И в статусе видим, что наша локальная ветка отстаёт от апстрим-ветки на 12 коммитов, мы можем теперь её синхронизовать командой git pull:

$ git pull
Updating d5b7a05a..f61172b8
Fast-forward
 .github/ISSUE_TEMPLATE/config.yml |   4 +--
 CHANGES.rst                       |   8 ++++++
 CODE_OF_CONDUCT.md                |  76 ------------....-----------------------------
 CONTRIBUTING.rst                  | 238 ----------....-----
 README.md                         |  18 ++++++++-----
 docs/conf.py                      |   3 +++
 docs/contributing.rst             |   9 ++++++-
 src/flask/testing.py              |   3 ++-
 8 files changed, 35 insertions(+), 324 deletions(-)
 delete mode 100644 CODE_OF_CONDUCT.md
 delete mode 100644 CONTRIBUTING.rst

$ git status
On branch local-main
Your branch is up to date with 'origin/main'.

nothing to commit, working tree clean

И теперь статус показывает, что ветка синхронизована с апстримом.

Своевременное удаление локальных и апстрим-веток

Со временем в локальном репозитории начинают копиться ветки. Git по умолчанию самостоятельно никакие локальные данные не удаляет. Апстрим-ветки (например, origin/branch-name) рекомендуется периодически подчищать командой git fetch --prune или сокращённо git fetch -p: она удаляет локальные апстрим ветки, для которых отсутствует соответствующая ветка во внешнем репозитории.

Например, при вызове git fetch -p, если во внешнем репозитории origin уже удалена ветка branch-name, а локально всё ещё есть origin/branch-name, то локальная ветка origin/branch-name будет удалена.

❈ ❈ ❈

Просто локальные рабочие ветки тоже рекомендую удалять, если они полностью синхронизованы с одноимённой веткой во внешнем репозитории. Посмотреть это можно командой git status (сначала, конечно, надо переключиться на нужную локальную рабочую ветку и запустить git fetch, чтобы синхронизовать состояние апстрим-веток).

Вот пример для ветки t-feature:

$ git fetch
...

$ git status t-feature
On branch t-feature
Your branch is up to date with 'origin/t-feature'.

nothing to commit, working tree clean

Мы видим, что локальная ветка t-feature полностью синхронизована со внешней одноимённой веткой из репозитория origin. Более того, в каталоге нет никаких изменённых или новых файлов. Такую локальную ветку можно лучше всего удалить, если вы не собираетесь ею пользоваться.

$ git switch main
...

$ git branch -d t-feature
warning: deleting branch 't-feature' that has been merged to
         'refs/remotes/origin/t-feature', but not yet merged to HEAD
Deleted branch t-feature (was 09032514).

На этот warning можно не обращать внимания. Если бы ветка не была полностью синхронизована с апстримом, команда git branch -d не смогла бы её удалить, это такой дополнительный уровень защиты. Выглядит это примерно так:

$ git branch -d t-main
error: the branch 't-main' is not fully merged
hint: If you are sure you want to delete it, run 'git branch -D t-main'
hint: Disable this message with "git config advice.forceDeleteBranch false"

Вы можете принудительно такую ветку удалить командой git branch -D, однако рекомендую её использовать только в исключительных ситуациях, а во всех остальных случаях пользоваться только git branch -d, так безопаснее.

Напомню, что ветка в git — это динамический указатель на коммит, который периодически сдвигается и начинает указывать на другой коммит. Удаление локальной полностью синхронизованной ветки фактически не удаляет никакие данные, вы позднее сможете восстановить ветку, используя сохранённую локальную апстрим-ветку вида origin/t-feature. Общее правило: если вы поработали над веткой и больше в ней никакой активности не ожидается, то удаляйте её с локального репозитория. Если вдруг возникнет необходимость, её можно будет восстановить.

Журнал коммитов / log

Для просмотра в терминале истории коммитов используется команда git log, по умолчанию она открывает журнал коммитов, начиная с текущего коммита в рабочей копии. Это чрезвычайно полезная команда, которой вы будете активно пользоваться. Ниже конкретные варианты, которыми я активно пользуюсь.

git log можно использовать как для файла, так и каталога, плюс она очень удобна для просмотра изменений без переключения ветки.

Полная история изменений вместе с собственно изменениями (diff)

Для просмотра всех изменений (коммитов), включая конкретные модификации для файлов, используется команда git log -p. Она очень удобна, когда вам нужно посмотреть все изменения в истории файла, например:

$ git log -p README.md

Вывод может быть весьма объёмным, особенно для большого репозитория.

Просмотр дерева коммитов с псевдографикой в терминале

Команда git log --oneline --graph — рисует в терминале историю коммитов в псевдографическом стиле:

$ git log --oneline --graph
*   f61172b8 (HEAD -> main, origin/main, origin/HEAD) Merge branch 'stable'
|\  
| * 959052fb use global contributing guide
| * 5b525e97 markdown formatting
* | 60a11a73 use global contributing guide
* | 6b361ce0 markdown formatting
* | 6b054f8f Merge branch 'stable'
|\| 
| *   f2674c5b fix type hint for `cli_runner.invoke` (#5647)
| |\  
| | * 54c3f87a fix type hint for `cli_runner.invoke`
| |/  
| *   ea08f155 update `__version__` deprecation (#5649)
| |\  
| | * b394a994 update `__version__` deprecation
| |/  
| * dcbe86bd start version 3.1.1
| * 18ffe1ea add gettext config for docs

Посмотреть историю коммитов с указанием списка затронутых файлов в каждом

По умолчанию git log показывает только базовую информацию о коммите: описание, даты и так далее. Чтобы показать ещё и затронутные коммитом имена файлов, используйте команду git log --name-status:

$ git log --name-status 959052fb
commit 959052fb8d49c4473f6d8a928d745ae7c38c7e95
Author: David Lord <[email protected]>
Date:   Sun Jan 5 09:02:41 2025 -0800

    use global contributing guide

    Remove the per-project files so we don't have to
    keep them in sync. GitHub's UI links to everything
    except the contributing guide, so add a section
    about that to the readme.

    (cherry picked from commit 60a11a730e8aa2450115385798ab8710804c6409)

M       .github/ISSUE_TEMPLATE/config.yml
D       CODE_OF_CONDUCT.md
D       CONTRIBUTING.rst
M       README.md
M       docs/contributing.rst

commit 5b525e97970b1b9706b4415de79abfe33e5955fc
Author: David Lord <[email protected]>
Date:   Sun Jan 5 09:01:49 2025 -0800

    markdown formatting

    (cherry picked from commit 6b361ce06b08454329286214072139228e8c2f33)

M       README.md

Здесь для примера показана история, начиная с коммита 959052fb.

Reference log / reflog / рефлог

Рефлог — это история «переключений» рабочей копии. Каждый раз, когда вы делаете git switch <BRANCH>, в рефлог записывается информация об этом. Просмотреть журнал можно командой git reflog, вот как он выглядит на репозитории, над которым выполняли действия из предыдущих разделов:

$ git reflog
f61172b8 (HEAD -> main, origin/main, origin/HEAD) HEAD@{0}: checkout: moving from patch-1 to main
812b9cc2 (flask-session/patch-1, patch-1) HEAD@{1}: checkout: moving from main to patch-1
f61172b8 (HEAD -> main, origin/main, origin/HEAD) HEAD@{2}: checkout: moving from patch-1 to main
812b9cc2 (flask-session/patch-1, patch-1) HEAD@{3}: checkout: moving from main to patch-1
f61172b8 (HEAD -> main, origin/main, origin/HEAD) HEAD@{4}: checkout: moving from fs-main to main
1e3a1cbf (flask-session/main, fs-main) HEAD@{5}: checkout: moving from main to fs-main
f61172b8 (HEAD -> main, origin/main, origin/HEAD) HEAD@{6}: clone: from https://github.com/sigsergv/flask.git
$

Если теперь переключиться на другой коммит или ветку, то в рефлоге появится новая запись:

$ git switch development
branch 'development' set up to track 'flask-session/development'.
Switched to a new branch 'development'

$ git reflog
bc2fe679 (HEAD -> development, flask-session/development, flask-session/HEAD) HEAD@{0}: checkout: moving from main to development
f61172b8 (origin/main, origin/HEAD, main) HEAD@{1}: checkout: moving from patch-1 to main
812b9cc2 (flask-session/patch-1, patch-1) HEAD@{2}: checkout: moving from main to patch-1
f61172b8 (origin/main, origin/HEAD, main) HEAD@{3}: checkout: moving from patch-1 to main
812b9cc2 (flask-session/patch-1, patch-1) HEAD@{4}: checkout: moving from main to patch-1
f61172b8 (origin/main, origin/HEAD, main) HEAD@{5}: checkout: moving from fs-main to main
1e3a1cbf (flask-session/main, fs-main) HEAD@{6}: checkout: moving from main to fs-main
f61172b8 (origin/main, origin/HEAD, main) HEAD@{7}: clone: from https://github.com/sigsergv/flask.git

Переключимся снова на ветку main:

$ git switch main
Switched to branch 'main'
Your branch is up to date with 'origin/main'.

$ git reflog
f61172b8 (HEAD -> main, origin/main, origin/HEAD) HEAD@{0}: checkout: moving from development to main
bc2fe679 (flask-session/development, flask-session/HEAD, development) HEAD@{1}: checkout: moving from main to development
f61172b8 (HEAD -> main, origin/main, origin/HEAD) HEAD@{2}: checkout: moving from patch-1 to main
812b9cc2 (flask-session/patch-1, patch-1) HEAD@{3}: checkout: moving from main to patch-1
f61172b8 (HEAD -> main, origin/main, origin/HEAD) HEAD@{4}: checkout: moving from patch-1 to main
812b9cc2 (flask-session/patch-1, patch-1) HEAD@{5}: checkout: moving from main to patch-1
f61172b8 (HEAD -> main, origin/main, origin/HEAD) HEAD@{6}: checkout: moving from fs-main to main
1e3a1cbf (flask-session/main, fs-main) HEAD@{7}: checkout: moving from main to fs-main
f61172b8 (HEAD -> main, origin/main, origin/HEAD) HEAD@{8}: clone: from https://github.com/sigsergv/flask.git

Формат вывода git reflog регулируется аргументами в командной строке, в списке записей сверху находится самая последняя и дальше идут более ранние. В нашем варианте в каждой строке сначала идёт идентификатор коммита, дальше специальный референс вида HEAD@{N}, который можно трактовать как «коммит, который был в рабочей копии N обновлений HEAD назад».

В команде также можно указать название ветки, чтобы посмотреть, как эта ветка менялась с течением времени в локальном репозитории:

$ git reflog main
f61172b8 (HEAD -> main, origin/main, origin/HEAD) main@{0}: clone: from https://github.com/sigsergv/flask.git

Если вы хотите вместо номера видеть дату, когда было переключение, используйте аргумент --date=iso:

$ git reflog main --date=iso
f61172b8 (HEAD -> main, origin/main, origin/HEAD) main@{2025-02-28 19:43:46 +0700}: clone: from https://github.com/sigsergv/flask.git

Также git reflog принимает многие аргументы команды git log.

Команда git reflog окажется особо полезной для восстановления недавно удалённой ветки, в этом журнале будет последний коммит в ней. И если сборщик мусора ещё не почистил базу, вы сможете восстановить удалённую ветку с названием fs-branch, указав в команде git branch fs-branch <COMMIT> идентификатор последнего коммита в этой ветке, выведенный через reflog.

Синхронизация локального и внешнего репозитория

Как работает синхронизация локального репозитория с внешним

Синхронизация в git устроена очень просто, выше я уже писал про неё, а тут в немного более расширенной форме повторю.

Напомню, что практически все операции в git происходят в локальном репозитории. Содержимое внешнего репозитория хранится вместе с локальными объектами, коммиты организованы в виде одного ориентированного графа, на определённые узлы которого развешаны метки со всех репозиториев: теги и ветки.

Вот несколько упрощённая схема, как работает команда git fetch origin, которая «скачивает» новые коммиты с внешнего репозитория, который в локальном сконфигурирован под именем origin:

  • по имени внешнего репозитория получает его URL;
  • при необходимости авторизуется;
  • скачивает новые объекты;
  • сохраняет объекты в локальную базу из каталога .git;
  • обновляет референсы для данного внешнего репозитория (refs/remotes/origin/*).

Под «обновлением референсов» подразумевается их перезаписывание, если они были изменены; и добавление новых, если таких ещё не было или они были вручную удалены.

Таким образом, в локальном репозитории после git fetch всегда остаётся корректный образ внешнего репозитория, все изменения в референсах для внешних репозиториев будут принудительно перезаписаны.

Целостность системы обеспечивается автоматически, конфликты в принципе невозможны (напомню, что я говорю сейчас только об операции fetch, которая не меняет рабочую копию).

В локальном репозитории со временем копятся ветки внешнего репозитория, которых во внешнем уже нет. Полезно периодически запускать команду git fetch --prunt или сокращённо git fetch -p, она удаляет все внешние референсы, которых уже нет на внешнем репозитории. Например, это касается внешних веток, которые можно посмотреть командой git branch -r

git pull и git merge

Команда git pull используется для обновления локального репозитория и для обновления текущей рабочей копии. По умолчанию она является объединением последовательных команд git fetch (для обновления локального репозитория) и git merge (для обновления рабочей копии с апстрим-ветки).

О git fetch я уже выше рассказывал, это недеструктивная простая операция, которая обновляет локальную базу объектов. В том числе обновляет и внешние ветки с именами вида origin/branch-name.

git merge отвечает за слияние («мержинг») двух коммитов. Если запускать git merge без аргументов, то в качестве коммита, с которого пойдёт слияние, будет взята апстрим-ветка из конфига. Напомню, что ветка в git — это по сути динамически сдвигающийся указатель на коммит, поэтому когда речь идёт о ветке, подразумевается коммит, на который в данный момент указывает ветки.

Для объединения git fetch и git merge в одну команду есть рациональное объяснение: вы можете забыть сделать git fetch и смержите свою ветку с неактуальным состоянием внешней ветки. Я это часто наблюдал в реальном проекте.

Слово merge в английском языке обозначает в том числе процесс встраивания автомобиля на оживлённую трассу со второстепенной дороги.

В результате мержинга двух коммитов должен получиться один коммит, включающий в себя изменения из обоих. При этом помним, что за каждым коммитом стоит на самом деле цепочка изменений. Чаще всего делается мержинг двух веток, которые когда-то ранее разошлись и теперь требуют обновления и слияния.

Если при этом в ветке, которую вы вливаете уже есть все изменения из ветки, в которую вы вливаете, то результирующий merge-коммит будет пустым, без фактических изменений, а только с описанием, датой и так далее.

Упрощённо процесс слияния двух веток можно описать так:

  • рабочая копия переключена на ветку B1 с топовым коммитом C1;
  • апстримом для ветки B1 является ветка origin/B1 с топовым коммитом X1;
  • запускаем команду git merge;
  • git выделяет коммиты, которые мы хотим смержить, это C1 и X1;
  • git ищёт коммит в графе, на котором «разошлись» ветки B1 и origin/B1 (другими словами, это первый общий коммит у «предков» коммитов C1 и X1), допустим, это C0;
  • как только такой коммит найден, git вычисляет разницу между коммитами C0 и X1 и пытается наложить этот патч на рабочую копию;
  • если патч удалось наложить без конфликтов, то создаётся новый merge-commit, у которого в качестве родительских коммитов указаны два коммита, а не один, как обычно;
  • если патч автоматически наложить не удалось, процесс прерывается и пользователь должен самостоятельно разрешить конфликты, после чего продолжить мержинг, после чего так же создаётся коммит с двумя родителями.

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

Сначала переключим рабочую копию на ветку t-main, в которую будем мержить ветку origin/t-feature:

$ git switch t-main
branch 't-main' set up to track 'origin/t-main'.
Switched to a new branch 't-main'

Выше я уже писал, что тут на самом деле создаётся новая локальная ветка с апстримом на внешнюю.

Вот как в целом выглядит кусок графа коммитов для нашего примера:

merge initial state

И вот как он же выглядит в gitk (команда gitk origin/t-main origin/t-feature, она нам показывает граф коммитов так, чтобы были одновременно видны коммиты из референсов origin/t-main и origin/t-feature):

merge initial state - gitk

Что мы видим из этого графа:

  • рабочая копия находится на локальной ветке t-main, пока совпадающей с внешней веткой origin/t-main (коммит a9b6df3);
  • ветки origin/t-main и origin/t-feature разошлись на коммите с идентификатором 6340a48;
  • в ветке t-feature было сделано два коммита после 6340a48;
  • в ветке t-main было сделано три коммита после 6340a48.

Наша цель: смержить ветку origin/t-feature на локальную ветку t-main, в результате этого в локальной ветке t-main должны появиться те изменения, которые были сделаны в origin/t-feature после расхождения на коммите 6340a48. Эти изменения появляются в виде специального коммита, который принято называть merge commit.

Для начала я очень рекомендую запустить gitk в каталоге рабочей копии и посмотреть, какие именно изменения были в коммитах. Так как мы хотим посмотреть изменения сразу в двух ветках, запускать нужно с указанием двух референсов: gitk origin/t-main origin/t-feature. Все изменения были в одном файле: README.md.

Если мы выполним команду git merge без аргументов, то увидим логичное сообщение:

$ git merge
Already up-to-date.
$

git merge без аргументов пытается смержить в локальную рабочую копию апстрим-ветку, но сейчас сама ветка и её апстрим указывают на один коммит, поэтому делать ничего не нужно. Поэтому укажем явно, что мы хотим влить ветку origin/t-feature в нашу рабочую копию:

$ git merge origin/t-feature
Auto-merging README.md
CONFLICT (content): Merge conflict in README.md
Automatic merge failed; fix conflicts and then commit the result.
$

Я специально сделал эти коммиты такими, чтобы попытка мержинга вызвала конфликт: автоматическая система не смогла надёжно слить изменения, поэтому будем делать это вручную.

Сначала смотрим статус рабочей копии:

$ git status
On branch t-main
Your branch is up to date with 'origin/t-main'.

You have unmerged paths.
  (fix conflicts and run "git commit")
  (use "git merge --abort" to abort the merge)

Unmerged paths:
  (use "git add <file>..." to mark resolution)
    both modified:   README.md

no changes added to commit (use "git add" and/or "git commit -a")

Разрешение конфликта происходит в текстовом редакторе, вы должны открыть файл SAMPLE.txt и отредактировать его нужным образом. Сейчас содержимое файла (точнее, его верхние строчки, где я делал все изменения) выглядит так:

# Flask

Dummy commit for branch t-main.

<<<<<<< HEAD
2nd change in branch t-main.

3rd change in branch t-main.

4th change in branch t-main.
=======
1st commit in feature branch t-feature.

2nd commit in feature branch t-feature.
>>>>>>> origin/t-feature

Flask is a lightweight [WSGI] web application framework. It is designed
to make getting started quick and easy, with the ability to scale up to

Проблемный блок (который система не смогла автоматически пропатчить) находится между строчкам с маркерами <<<<<<< HEAD и >>>>>>> origin/t-feature. Блок состоит из двух частей, разделённых маркером =======, выше этого маркера текст из рабочей копии, ниже — из той ветки, которую мы мержим (origin/t-feature).

Чтобы корректно разрешить конфликт, вы должны вместо проблемного блока оставить правильный код. Под правильным подразумевает код, содержащий все необходимые изменения. В нашей ситуации всё достаточно просто: мы должны оставить код из обоих частей, отредактируем файл, чтобы его содержимое стало таким:

# Flask

Dummy commit for branch t-main.

2nd change in branch t-main.

3rd change in branch t-main.

4th change in branch t-main.

1st commit in feature branch t-feature.

2nd commit in feature branch t-feature.

Flask is a lightweight [WSGI] web application framework. It is designed
to make getting started quick and easy, with the ability to scale up to

После разрешения конфликтов в файле не должно остаться служебных маркеров <<<<<<<, ======= и >>>>>>>!

Теперь нужно сказать git, что конфликты в файле убраны, делается это командой git add (и не забываем про git status):

$ git add git add README.md

$ git status
On branch t-main
Your branch is up to date with 'origin/t-main'.

All conflicts fixed but you are still merging.
  (use "git commit" to conclude merge)

Changes to be committed:
    modified:   README.md

Теперь нужно завершить мерж, делается это командой git commit (это написано в выводе сообщения о статусе). В процессе откроется редактор, где нужно будет ввести сообщение для нового коммита, его шаблон уже подготовлен и в нём много полезной информации в комментариях. Текст шаблона, в принципе, уже нас устраивает, поэтому сохраняем файл и закрываем редактор.

$ git commit
[t-main 47ec6c6e] Merge remote-tracking branch 'origin/t-feature' into t-main
$

Сообщение после команды нам говорит, что был создан мерж-коммит с идентификатором 47ec6c6. Сразу же посмотрим, что из себя представляет этот коммит во внутренней базе объектов:

$ git cat-file -p 47ec6c6
tree 05a285455a34f85ea8ab34b1542fc9d75ced7a64
parent a9b6df3168f6d15b718702c9b8be0e220499107d
parent 09032514ab09fea370ebb3281d625024d516784a
author Sergey Stolyarov <[email protected]> 1740765704 +0700
committer Sergey Stolyarov <[email protected]> 1740765704 +0700

Merge remote-tracking branch 'origin/t-feature' into t-main

Видим, что у коммита 47ec6c6e не один, а два родителя, это отражает факт слияния. При этом первый parent-commit — это всегда родитель на той ветке, на которой происходил мержинг (в нашем случае это ветка t-main). Второй родительский коммит — это коммит из ветки, которая вливалась, у нас это t-feature.

Модифицированный мерж-коммитом граф выглядит так:

commit graph after merge

Если вы все эти операции проделали у себя, то результирующий мерж-коммит у вас будет иметь другой идентификатор, так как в подсчёт идентификатора (то есть SHA1-суммы) входит имя, емейл и время, а у вас они будут другими.

А вот как это выглядит в gitk:

gitk after merge

Сразу же несколько важных наблюдений:

  • локальная ветка t-main теперь указывает на другой коммит, при этом на предыдущем коммите этой ветки по-прежнему висит метка ветки origin/ss-master, то есть наша локальная ветка теперь на один коммит впереди апстрима;
  • соответственно, теперь в рабочей копии находится другой коммит;
  • первый коммит-родитель (parent a9b6df3168f6d15b718702c9b8be0e220499107d) — это коммит, на который вы мержили второй коммит-родитель (parent 09032514ab09fea370ebb3281d625024d516784a).

Важное замечание. Никогда не делайте merge на рабочей копии с незакоммиченными изменениями, это чревато непредсказуемыми и сложнорешаемыми проблемами.

Если в процессе мержинга что-то пошло не так, например, вы удалили лишнее при правке конфликтного файла, вы можете полностью отменить merge и вернуться на состояние рабочей копии до команды git merge. Делается это командой git merge --abort. Однако это корректно сработает только при условии, что перед началом мержинга у вас не было незакоммиченных изменений.

git rebase

Операция git rebase позволяет перенести (перебазировать) цепочку коммитов на другую базу. По сути это означает последовательное применение коммитов из цепочки поверх какого-то другого коммита. Rebase чаще всего используется, когда вы хотите получить максимально линейную историю коммитов.

Вот пример типичного сценария использования rebase. Есть главная ветка (обычно это master или main), в определённый момент вы начинаете работу над фичей в ветке с названием feature с последнего на тот момент коммита из главной ветки:

gitk after merge

Далее в master и feature параллельно идёт работа и в них независимо появляются свои коммиты, и теперь ветки выглядят так:

gitk after merge

В master появились коммиты M2, M3; в ветке feature — коммиты F1, F2, F3. Вы хотите обновить ветку, чтобы в ней появились все новые коммиты из master и при этом продолжить дальше работать в feature. В прошлом разделе мы рассматривали merge, однако после него история изменений в ветке будет выглядеть не очень хорошо: появится новый merge-коммит и при дальнейшей работе вместо линейной истории коммитов вы получите сложное дерево из ветвлений и слияний.

Альтернативный подход здесь — rebase, внутри ветки feature вы запускаете команду git rebase origin/master, после чего ваши коммиты M1, M2, M3 как бы повторяются заново, но уже на базе самого свежего коммита из master — M3. И теперь репозиторий выглядит так:

gitk after merge

Коммиты F1', F2', F3' имеют то же содержание, что и F1, F2, F3, но так как у них изменена база (с M1 на M3), теперь это другие объекты-коммиты, с новыми идентификаторами. При этом указатель ветки feature переносится на коммит F3', а ваши предыдущие коммиты теряют связь с именованными референсами (то есть названием ветки, на иллюстрации она показан перечёркнутой). Они в репозитории по-прежнему доступны через идентификаторы, но при работе сборщика мусора будут удалены.

git при работе команды git rebase сначала ищет общую базу в текущей ветке и потом все коммиты после неё переносит на новую.

Обратите внимание, что в команде git rebase origin/master используется не master, а origin/master, это сделано специально для ситуации, если вы забыли актуализировать локальную ветку master с её внешним оригиналом. И не забывайте перед git rebase сделать git fetch.

Если вы при создании ветки feature включили её трекинг с веткой master, то вместо последовательности команд git fetch и git rebase origin/master можно выполнить одну: git pull --rebase, аргумент --rebase выполняет rebase вместо merge.

❈ ❈ ❈

Если вы в этом момент решите провести мерж изменений из feature в master через git merge, то по умолчанию git выполнит так называемый fast-forward merge, то есть указатель ветки master будет просто перенесён на коммит F3' без создания дополнительного merge-коммита. И в этот момент в ветках master и feature последний коммит станет одним и тем же (с одинаковым идентификатором).

История коммитов и относительные референсы

Существуют специальные динамические референсы, указывающие на определённые коммиты в истории.

<rev>~<n>, где ~ символ тильды
указывает на коммит, стоящий <n> коммитов назад по истории, переходы идут по первому коммиту-родителю. Примеры: HEAD~1, HEAD~ (эквивалентно HEAD~1), HEAD~~, HEAD~2 (эквивалентно HEAD~~). Вместо HEAD может быть любой другой референс или идентификатор коммита.
<rev>^<n>, где ^ символ «крышки»
указывает на <n> родителя коммита, например, HEAD^ эквивалентно HEAD^1 и указывает на первого родителя (то есть эквивалентно ещё и HEAD~), HEAD^2 указывает на второго предка (имеет смысл только для merge-коммитов). В некоторых оболочках, например, zsh, такой референс нужно заключать в кавычки, чтобы исключить дополнительные попытки оболочки распарсить этот параметр.

В качестве примера посмотрим на рисунок выше с репозиторием после мержинга, я на нём развесил дополнительные метки с референсами:

commit graph after merge

На такой модели, где история (по времени) идёт слева направо, HEAD~ переходит по горизонтали, а HEAD^ — по вертикали. Также некоторых коммитов можно достичь несколькими путями.

Относительные референсы можно использовать наравне с обычными. Например, если вы хотите посмотреть diff между HEAD и вторым его предком:

$ git diff HEAD 'HEAD^2'
diff --git a/README.md b/README.md
index 46e67b3d..8a3f94c9 100644
--- a/README.md
+++ b/README.md
@@ -2,12 +2,6 @@

 Dummy commit for branch t-main.

-2nd change in branch t-main.
-
-3rd change in branch t-main.
-
-4th change in branch t-main.
-
 1st commit in feature branch t-feature.

 2nd commit in feature branch t-feature.

git push

Операция git push отправляет (публикует) коммиты, ветки и теги из локального репозитория на внешний. git push работает исключительно с уже записанными данными, изменения из рабочей копии она не трогает.

Вы можете прямо указать название remote или напрямую URL внешнего сервера, куда вы хотите отправить изменения. По умолчанию git пытается сам определить внешний репозиторий для отправки изменений, исходя из настроек, текущей ветки и так далее.

Также существуют определённые правила, как именно выбираются коммиты для отправки. Вы можете в аргументах команды указать, какие именно локальные референсы вы хотите отправить и какие референсы на внешнем сервере должны быть ими обновлены. Формат этого аргумента детально описан в мануале git push, здесь я в подробности вдаваться не буду, так как это достаточно сложная тема.

Если параметры обновляемых референсов не указаны, то используется стратегия, определяемая параметром конфига push.default:

nothing
команда git push будет выдавать ошибку, если явно не указаны параметры референсов для отправки;
current
будут запушены только коммиты из текущей ветки, целевый референсом будет ветка на внешнем сервере с таким же именем;
upstream
будут запушены только коммиты из текущей ветки, целевым референсом будет выбран апстрим текущей ветки (я выше писал, что это такое);
simple
работает как uptream, только дополнительно проверяется, что имя ветки апстрима совпадает с именем локальной ветки, по сути это означает, что push отработает только если для ветки в конфиге явно задан апстрим и ветка на внешнем сервере имеет такое же имя, что и локальная. Этот режим используется по умолчанию, начиная с версии git 2.0.
matching
будут запушены все ветки, имена которых совпадают с ветками на внешнем сервере. Этот режим использовался по умолчанию до версии git 2.0.

Самый безопасная стратегия — simple, именно она используется по умолчанию с версии git 2.0, если параметр конфига push.default явно не указан. Остальные стратегии следует использовать с осторожностью, особенно matching.

❈ ❈ ❈

Про режим simple я напишу подробнее. При создании ветки на основе какого-то внешнего референса, например, origin/master, вы можете получить подобную ошибку на команде git push:

fatal: The upstream branch of your current branch does not match
the name of your current branch.  To push to the upstream branch
on the remote, use

    git push origin HEAD:dev

To push to the branch of the same name on the remote, use

    git push origin HEAD

To choose either option permanently, see push.default in 'git help config'.

To avoid automatically configuring an upstream branch when its name
won't match the local branch, see option 'simple' of branch.autoSetupMerge
in 'git help config'.

Возникает она в таком сценарии, например:

  • вы начинаете новую фичу и создаёте ветку под неё такой командой: git switch -c test-feature origin/main
  • создаёте в ветке несколько коммитов
  • решаете опубликовать изменения в одноимённую ветку с тем же названием на внешнем репозитории

Если вы сразу после создания и переключения ветки посмотрите её статус (git status), то увидите, что в ней был включён трекинг с ветки origin/main (она теперь апстрим для ветки test-feature):

$ git status
On branch test-feature
Your branch is up to date with 'origin/main'.

nothing to commit, working tree clean

По этой причине при попытке опубликовать коммиты, git попытается это сделать на ветку main во внешнем репозитории, а вовсе не на ветку test-feature, а так как это может быть опасно, показывает ошибку и отклоняет операцию. Чтобы опубликовать на апстрим-ветке, нужно использовать команду git push origin HEAD

Я рекомендую не менять значение настройки push.default и оставить её в simple. Если же же вы хотите, чтобы при git push изменения всегда отправлялись с текущей ветки на одноимённую на внешнем репозитории, поменяйте настройку на upstream:

$ git config --global push.default upstream

❈ ❈ ❈

У git push есть ещё одно ограничение — по умолчанию команда не позволит вам запушить коммиты, если текущий верхний (последний) коммит ветки на внешнем сервере не является коммитом из истории локальной ветки. По сути это означает, что вы не сможете отправить коммиты на внешний сервер, если в вашей локальной ветке отсутствуют коммиты, добавленные во внешнюю ветку с момента последней синхронизации. Обычно это возникает, когда во внешнюю ветку кто-то ещё запушил коммиты после вашей последней синхронизации через git pull. Поэтому обычно перед git push рекомендуется сделать git pull. push можно зафорсить аргументом --force, однако это можно использовать, только если вы абсолютно точно осознаёте, что именно делаете.

git stash

Команда git stash сохраняет изменения в рабочей копии относительно HEAD и записывает их в виде патча в отдельное хранилище, после чего откатывает все изменения до HEAD. Затем можно эти изменения вытащить из хранилища и применить на рабочую копию.

Сохранение делается командой git stash save (это эквивалентно запуску без аргументов: git stash) или git stash save MESSAGE, чтобы создать именованный стеш.

Все стеши записываются в стек, команда git stash pop извлекает последний добавленный стеш и пытается его применить как патч на рабочую копию. Если процесс успешно завершается, стеш удаляется из стека.

Есть два базовых сценария, когда stash очень полезен:

  • сохранить стеш → сделать merge на текущую рабочую копию → восстановить стеш;
  • в процессе работы вы обнаруживаете, что работаете не с той веткой: сохранить стеш → переключиться на другую ветку → восстановить стеш.

Посмотреть весь стек стешей можно командой git stash --list.

Не следует увлекаться этой командой. В нормальном репозитории стек стешей должен быть пустым, и срок жизни записи в стеше не должен превышать нескольких минут.

Перед тем как запустить команду git stash всегда запускайте сначала git stash list, чтобы убедиться, что стек стешей пустой, если он не пустой, постарайтесь выяснить, что там лежит.

Вот примерно так выглядит работа со стешем:

$ git stash
Saved working directory and index state WIP on ss-master: 81c5a7e Merge remote-tracking branch 'origin/ss-branch' into ss-master
HEAD is now at 81c5a7e Merge remote-tracking branch 'origin/ss-branch' into ss-master
$ git stash list
stash@{0}: WIP on ss-master: 81c5a7e Merge remote-tracking branch 'origin/ss-branch' into ss-master

Здесь stash@{0} — идентификатор стеша в списке, вы его можете использовать как аргумент команды pop: git stash pop stash@{0}.

Обратите внимание, что в стеш попадают только отслеживаемые файлы! Если у вас в команде git status есть файлы в секции Untracked files, они в стеш не попадут, если вы их тоже хотите запомнить, добавьте сначала командой git add.

Вы можете удалить элементы из списка стешей командой git stash drop <STASH-ID>, например, git stash drop stash@{0}.

git blame

Команда git blame позволяет выяснить для каждой строчки файла из репозитория, кто и когда её «трогал».

Выглядит это так:

$ git blame tox.ini
323a840c5 (Daniel Neuhäuser 2013-05-18 18:27:49 +0200  1) [tox]
65b22926f (David Lord       2017-05-24 15:41:35 -0700  2) envlist =
1d610e44b (David Lord       2024-10-31 12:28:46 -0700  3)     py3{13,12,11,10,9}
8933d7544 (David Lord       2023-06-27 07:41:15 -0700  4)     pypy310
8f37c82f6 (David Lord       2024-10-31 13:11:06 -0700  5)     py313-min
52ccd6673 (David Lord       2024-10-18 13:19:51 -0700  6)     py39-dev
a0a61acde (David Lord       2020-04-03 17:23:21 -0700  7)     style
f405c6f19 (pgjones          2021-04-23 09:44:15 +0100  8)     typing
824e54803 (David Lord       2019-11-18 18:57:43 -0800  9)     docs
824e54803 (David Lord       2019-11-18 18:57:43 -0800 10) skip_missing_interpreters = true
...

В колонках последовательно: идентификатор коммита, автор, дата изменения, номер строки и собственно содержимое строки.

Аутентичность коммитов

В subversion принадлежность коммита определяется авторизационными данными пользователя, который коммит отправил на сервер. В git ничего такого нет. Вы можете в коммите указать любой email и любое имя и он будет успешно создан.

Однако вы можете подписывать коммиты своим PGP-ключом через gnupg. Естественно, у вас должен быть установлен и настроен gnupg для этого. Коммит будет подписан, если вы добавите аргумент -S<keyid>, например, так:

$ git commit [email protected] -m "Signing example, see file GPG.md for details"

В моём образцовом репозитории добавлена ветка signed-branch, последний коммит в которой подписан моим PGP-ключом. Если у вас установлен gnupg, вы можете проверифицировать этот коммит:

$ git log --show-signature -1 origin/signed-branch
commit fbad0194b722c1be0c9894941c6aa6565ea1c3cd (HEAD -> signed-branch, origin/signed-branch)
gpg: Signature made суббота,  1 марта 2025 г. 13:23:18 +07
gpg:                using RSA key 87E2946B041BCF318C4D5BA74695E6355B20C7D2
gpg:                issuer "[email protected]"
gpg: Can't check signature: No public key
Author: Sergey Stolyarov <[email protected]>
Date:   Sat Mar 1 13:23:18 2025 +0700

    Signing example, see file README.md for details

Верификация не прошла, так как у вас, скорее всего, нет моего публичного ключа (сообщение gpg: Can't check signature: No public key), вы можете его добавить командой:

$ gpg --keyserver hkp://keys.openpgp.org --recv-keys 0x4695E6355B20C7D2

И проверить снова:

$ commit fbad0194b722c1be0c9894941c6aa6565ea1c3cd (HEAD -> signed-branch, origin/signed-branch)
gpg: Signature made суббота,  1 марта 2025 г. 13:23:18 +07
gpg:                using RSA key 87E2946B041BCF318C4D5BA74695E6355B20C7D2
gpg:                issuer "[email protected]"
gpg: Good signature from "Sergey Stolyarov <[email protected]>" [unknown]
gpg: WARNING: This key is not certified with a trusted signature!
gpg:          There is no indication that the signature belongs to the owner.
Primary key fingerprint: 87E2 946B 041B CF31 8C4D  5BA7 4695 E635 5B20 C7D2
Author: Sergey Stolyarov <[email protected]>
Date:   Sat Mar 1 13:23:18 2025 +0700

    Signing example, see file README.md for details

На низком уровне цифровая подпись является частью объекта-коммита, то есть подпись нельзя изменить без изменения идентификатора коммита.

Помимо коммитов вы также можете подписывать аннотированные теги:

$ git tag -as -u [email protected] signed-tag 

Проверяется подпись у тега так:

$ git tag -v signed-tag
object fbad0194b722c1be0c9894941c6aa6565ea1c3cd
type commit
tag signed-tag
tagger Sergey Stolyarov <[email protected]> 1740810665 +0700

This is an example of signed tag.
gpg: Signature made суббота,  1 марта 2025 г. 13:31:19 +07
gpg:                using RSA key 87E2946B041BCF318C4D5BA74695E6355B20C7D2
gpg:                issuer "[email protected]"
gpg: Good signature from "Sergey Stolyarov <[email protected]>" [unknown]
gpg: WARNING: This key is not certified with a trusted signature!
gpg:          There is no indication that the signature belongs to the owner.
Primary key fingerprint: 87E2 946B 041B CF31 8C4D  5BA7 4695 E635 5B20 C7D2

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

Полезные рекомендации

Никогда не удаляйте склонированный репозиторий

xkcd 1597

Очень много людей в случае каких-нибудь проблем просто удаляют весь каталог репозитория и клонируют его заново. Это очень плохое решение: когда вы удаляете локальный репозиторий, вы теряете все локальные ветки, коммиты и теги, теряете рефлог, теряете возможность вернуться к промежуточным коммитам, которые были только в этом локальном репозитории, теряете стеш и много чего ещё.

Всегда старайтесь разобраться в проблеме и решить её без радикальных методов.

Магия .gitignore

Файл .gitignore позволяет указать маски файлов, на которые git не должен обращать внимания. Обычно туда добавляются файлы и каталоги, возникающие в процессе сборки продукта, рабочие конфиги, файлы с логами и так далее. .gitignore действует, начиная с каталога, в котором он лежит. Таким образом, вы можете создать глобальный файл и локальные в подкаталогах.

Подробно формат файла описан в официальной документации: https://git-scm.com/docs/gitignore#_pattern_format.

Пользуйтесь .gitignore, это очень полезно, но будьте крайне осторожны, если вы добавите туда лишнее, то эти файлы не будут добавлены в репозиторий и не будут отслеживаться в команде git status.

Если вы принудительно попытаетесь добавить файл, попадающий под маску из .gitignore, то получите такую ошибку, например, для нашего демонстрационного репозитория:

$ touch .coverageXXX

$ git add .coverageXXX
The following paths are ignored by one of your .gitignore files:
.coverageXXX
hint: Use -f if you really want to add them.
hint: Disable this message with "git config set advice.addIgnoredFile false"

Однако если игнорируемый файл уже находится под контролем git, все изменения в нём обрабатываются как обычно: он показывается в status, при записе коммита не выдаётся никаких особых предупреждений.

❈ ❈ ❈

Помимо файла .gitignore маски файлов могут также храниться в файле .git/info/exclude и конфигурационной переменной core.excludesFile. Однако я советую пользоваться только файлом .gitignore, иначе вы можете непредсказуемо сломать работу всех репозиториев на машине.

Используйте алиасы (alias) команд

Git позволяет создавать собственные команды (псевдонимы, алиасы) для более длинных git-команд с аргументами. Например, вот так создаётся алиас git ci для команды git commit:

$ git config --global alias.ci 'commit'

Обратите внимание на аргумент --global, если его не указать, то вы создате алиас только для текущего репозитория.

Теперь вы можете вместо git commit набирать просто git ci.

Или вот алиаса для команды git show --name-status REF, которая позволяет для референса REF показать информацию по соответствующему коммиту вместе со списком затронутых файлов:

$ git config --global alias.info 'show --name-status'

Результат выглядит так:

$ git info 1.1.1
commit ffc68840f821fb0a4c41a7b2b4eaad6d71f539b7 (tag: 1.1.1)
Author: David Lord <[email protected]>
Date:   Mon Jul 8 10:55:25 2019 -0700

    release version 1.1.1

M       CHANGES.rst
M       README.rst
M       setup.py
M       src/flask/__init__.py

Посмотреть список всех глобальных алиасов можно командой:

$ git config --global --list | grep '^alias'

Управление конфигурацией

Чётко разделяйте глобальную (в файле ~/.gitconfig) и локальную конфигурации. Файл глобальной конфигурации лучше всего сохранить где-то и переиспользовать везде, где вы работаете с git.

Например, свой конфиг я храню в гитхабе: https://github.com/sigsergv/dotfiles/blob/master/global-gitconfig. В нём есть один важный элемент — параметр user.useConfigOnly, когда он выставлен в true, git не будет пытаться при коммите как-то угадать/сгенерировать имя или e-mail. Также в этом конфиге нет параметра user.email, в сочетании с user.useConfigOnly это означает, что если в каком-то репозитории не будет указан e-mail, то при коммите будет ошибка. Этот подход позволяет использовать разные e-mail адреса в разных ситуациях, например, разделение рабочего и персонального репозиториев.

Разные URL для push и pull/fetch

Если вы работаете над публичным репозиторием с github, gitlab или ещё какого подобного сервиса, то у вас всегда есть два разных способа указания адреса внешнего репозитория: через https или ssh. Для публичного репозитория очень удобно использовать https-адрес для операций fetch/pull, а ssh для push.

Обычно вы клонируете репозиторий по тому адресу, который указывает, например, github. В этом случае по умолчанию адреса для push и fetch совпадают (их можно посмотреть командой git remote -v):

$ git remote -v
origin  [email protected]:sigsergv/flask.git (fetch)
origin  [email protected]:sigsergv/flask.git (push)

Однако удобно для операций fetch с публичного репозитория успользовать https-адреса, так как они не требуют дополнительной локальной аутентификации для использования ssh-ключа, например. Установить разные адреса для push и fetch/pull можно такими командами:

$ git remote set-url origin https://github.com/sigsergv/flask.git

$ git remote set-url origin --push [email protected]:sigsergv/flask.git

$ git remote -v                                                     
origin  https://github.com/sigsergv/flask.git (fetch)
origin  [email protected]:sigsergv/flask.git (push)

Видим, что теперь адреса для fetch и push разные.

Оба адреса обязательно должны указывать на один репозиторий!

Что осталось за кадром

За рамками статьи осталось несколько тем, например, подмодули — они заслуживает отдельной статьи, из-за значительной копцептуальной сложности.

Также за кадром остались процессы коммитов на серверах типа github/gitlab, там схема внесения изменений в репозиторий значительно отличается от классического push.

В git имеется огромное количество команд, аргументов команд, настроек, которые радикально меняют поведение. У меня была цель показать, как git устроен концептуально, какая именно модель данных лежит в его основе. Эта модель очень простая, очень логичная и позволяет в будущем добавлять новые возможности, вообще никак не меняя что-то на низком уровне. Однако система команд git не очень хорошо сделана, хотя со временем значительно улучшается. Практически все старые команды из старых мануалов и статей по-прежнему работают в новой версии, но я специально их полностью исключил из текста. Это касается в первую очередь git checkout — не используйте её вообще.

Комментарии

Текст комментария (допустимая разметка: *курсив*, **полужирная**, [ссылка](http://example.com) или <http://example.com>) Посетители-анонимы, обратите внимение, что более чем одна гиперссылка в тексте (включая оную из поля «веб-сайт») приведёт к блокировке комментария для модерации. Зайдите на сайта с использованием аккаунта на twitter, например, чтобы посылать комментарии без этого ограничения.
Имя (обязательно, 50 символов или меньше)
Опциональный email, на который получать ответы (не будет опубликован)
Веб-сайт
© 2006—2024 Sergey Stolyarov | Работает на pyrengine