Git
Git — распределённая система контроля версий (version control system, VCS): она хранит историю изменений файлов, позволяет вернуться к любой прошлой версии и объединяет работу нескольких человек над одним проектом. Для научной работы Git является таким же обязательным инструментом, как Python или LaTeX.
Слайды к главе. Материал главы изложен также во второй половине лекции «ИИ и Git» с демонстрациями в терминале; слайды лекции доступны на сайте книги и в PDF.
Назначение контроля версий
Рассмотрим типичную курсовую работу с расчётами:
processing.py
processing_new.py
processing_final.py
processing_final_v2.py
thesis_final_v2_FINAL(2).py
thesis_final_v2_FINAL(2)_fixed_mass.py
Через месяц требуется перегенерировать третий рисунок для статьи, и никто, включая автора, не помнит, каким из файлов он построен. При этом «финальная» версия даёт другой хвост распределения, поскольку правка константы, сделанная в процессе работы, осталась неучтённой.
Контроль версий решает три проблемы:
- История вместо множества файлов. В проекте хранится один
processing.py, а все его прошлые версии остаются в Git. К любой из них можно вернуться, любые две можно сравнить построчно. - Воспроизводимость расчётов. Каждое состояние проекта имеет уникальный идентификатор, хеш коммита. Если записать его рядом с результатом, через год код, построивший график, восстанавливается дословно. Запись «Рисунок 3 построен на коммите
a3f2c17» и является воспроизводимостью. - Совместная работа. Когда над обработкой данных работают три человека, Git объединяет их изменения и показывает, кто, что и зачем изменил. Пересылать архивы
code_v7_new.zipпо почте не требуется.
Репозиторий на сервере служит и резервной копией: после вечерней отправки изменений на сервер выход из строя диска ноутбука перестаёт быть катастрофой.
Устройство Git
Устройство Git основано на трёх идеях, из которых выводится почти всё поведение команд.
Снимки, а не различия
Git хранит не «патчи к предыдущей версии», а снимки (snapshots). Каждый коммит представляет собой состояние всего проекта в момент фиксации плюс метаданные: автор, дата, сообщение и ссылка на родительский коммит. Неизменившиеся файлы не копируются, Git ссылается на уже сохранённые объекты, и репозиторий не разрастается.
Каждый объект адресуется хешем SHA-1, строкой вида a3f2c17b... из git log; в командах достаточно первых 6–8 символов.
Три состояния
Файлы проекта находятся в трёх зонах:
рабочая директория ──► индекс (staging area) ──► репозиторий (.git)
правишь файлы git add: отбираешь git commit:
изменения для коммита фиксируешь снимок
- Рабочая директория (working directory) содержит обычные файлы, которые редактирует разработчик.
- Индекс (staging area) — промежуточная область перед коммитом. Командой
git addв неё помещаются изменения, которые войдут в следующий снимок. - Репозиторий находится в скрытой папке
.git, хранящей все коммиты. Коммит, недостижимый ни через ветку, ни через тег, сохраняется ещё около месяца записью вgit reflog, а затем удаляется сборщиком мусора, поэтому спасательный приём из справочной таблицы в конце главы действует ограниченное время.
Индекс кажется избыточным до тех пор, пока не потребуется зафиксировать часть накопленных правок: исправление формулы одним коммитом, эксперименты с графиками — другим.
Коммиты образуют граф
Каждый коммит ссылается на родителя, поэтому история образует направленный ациклический граф (DAG). Пока над проектом работает один человек, граф вырождается в цепочку; ветки заставляют его ветвиться, слияния — сходиться.
A ─── B ─── C ─────── F main
└── D ─── E ───┘ fft-filter
Ветка — подвижный указатель на коммит, файл размером 41 байт внутри .git; копия проекта не создаётся, поэтому ветки создаются мгновенно и без затрат. HEAD указывает на текущее положение в графе.
Базовый цикл работы
Разовая настройка
# имя и почта автора попадут в каждый коммит
git config --global user.name "Lev Landau"
git config --global user.email "l.landau@g.nsu.ru"
# называть главную ветку main, как принято на GitHub
git config --global init.defaultBranch main
Создать или склонировать репозиторий
# превратить текущую папку в репозиторий (появится скрытая папка .git)
git init
# или склонировать существующий проект с сервера
git clone https://github.com/nsu-phys/beam-dynamics.git
Путь правки: рабочий каталог, индекс, коммит
В цикле, описанном ниже, проходит почти вся повседневная работа.
git status # что изменилось? (спрашивай постоянно)
git add processing.py # добавить файл в индекс
git add plots/ # можно папку целиком
git add -p # интерактивно, по кускам — очень полезно
git commit -m "Учтена поправка на температуру датчика"
git status # убедиться, что всё зафиксировано
git status показывает состояние всех трёх зон и подсказывает следующий шаг. На первых порах эту команду имеет смысл вызывать до и после каждой операции.
Просмотр истории и различий
git log # полная история с авторами и датами
git log --oneline # по строчке на коммит
git log --oneline --graph --all # весь граф с ветками, псевдографикой
git diff # что поменялось, но ещё не добавлено в индекс
git diff --staged # что уже в индексе и войдёт в коммит
git diff a3f2c17 HEAD -- processing.py # как файл изменился со старого коммита
git show a3f2c17 # что именно сделал этот коммит
.gitignore: исключение файлов из репозитория
Git отслеживает только явно добавленные файлы, однако git status перечисляет всё содержимое каталога, от кешей Python до гигабайтных файлов с данными. Файл .gitignore в корне проекта задаёт, что нужно игнорировать. Заготовка для научного проекта приведена ниже:
# байткод и кеши Python
__pycache__/
*.pyc
.ipynb_checkpoints/
# виртуальное окружение
.venv/
# данные измерений и результаты расчётов — им не место в git
data/
*.h5
*.root
*.npz
# тяжёлые артефакты: чекпойнты моделей, дампы
checkpoints/
*.ckpt
# мусор редакторов и ОС
.idea/
.vscode/
.DS_Store
# LaTeX-мусор
*.aux
*.synctex.gz
В репозитории хранится написанное вручную (код, тексты, конфигурации) и не хранится измеренное на установке или полученное расчётом. Сам .gitignore фиксируется в репозитории: правила исключений являются общими для всей группы.
Ветки
Ветка позволяет экспериментировать, не затрагивая работающий код: для проверки другого алгоритма фильтрации сигнала не требуется комментировать половину файла, достаточно создать ветку, названную по сути эксперимента.
git branch # список веток, звёздочка — текущая
git switch -c fft-filter # создать ветку и переключиться на неё
# ...правишь и коммитишь сколько угодно...
git switch main # вернуться на main: файлы мгновенно
# станут такими, какими были там
git switch появился в Git 2.23; в старых руководствах те же действия выполняются командами git checkout -b fft-filter и git checkout main.
Слияние
Когда эксперимент в ветке завершён успешно, ветка вливается обратно:
git switch main
git merge fft-filter # влить коммиты fft-filter в main
git branch -d fft-filter # удалить ненужный больше указатель
Если main с момента ветвления не изменялся, Git передвинет указатель вперёд (fast-forward). Если изменялся, будет создан коммит слияния с двумя родителями, и граф из приведённого выше рисунка сходится в одну линию.
Конфликты
Если в двух ветках изменены одни и те же строки, Git не будет выбирать версию самостоятельно и вставит в файл маркеры, размечающие оба варианта:
<<<<<<< HEAD
dt = 1e-12 # шаг интегрирования для жёсткой задачи
=======
dt = 5e-12 # компромисс между точностью и скоростью
>>>>>>> fft-filter
Конфликт является не ошибкой, а вопросом к разработчику. Необходимо отредактировать файл, оставив правильный вариант или скомбинировав оба, удалить маркеры и завершить слияние:
git add integrator.py # пометить конфликт разрешённым
git commit # завершить merge
Если принято решение отказаться от слияния, вместо этих двух команд требуется одна:
git merge --abort # откатить слияние целиком
После git commit сливать уже нечего, и git merge --abort сообщит, что отменять нечего.
Чем мельче коммиты и чем чаще выполняется слияние веток, тем реже возникают конфликты и тем проще они разрешаются.
Удалённые репозитории
Пока коммиты находятся только на ноутбуке разработчика, нет ни резервной копии, ни совместной работы. Удалённый репозиторий (remote) хранит копию проекта на GitHub, GitLab или на институтской машине группы.
git remote -v # список удалённых репозиториев
# после git clone там уже есть origin — адрес, откуда клонировал
# привязать локальный репозиторий к созданному на GitHub
git remote add origin git@github.com:username/beam-dynamics.git
git push -u origin main # отправить ветку main на сервер;
# -u запоминает связку, дальше просто git push
git fetch # скачать чужие коммиты, НЕ трогая рабочие файлы
git pull # fetch + merge: скачать и влить в текущую ветку
git push # отправить свои коммиты
git fetch безопасен всегда: он лишь обновляет локальные сведения о сервере, после чего можно просмотреть git log origin/main и изучить коммиты коллег. git pull объединяет fetch с немедленным слиянием. Типовой ритм работы: в начале сеанса выполняется git pull, по окончании — git push. Чем чаще выполняется синхронизация, тем меньше конфликтов.
Fork + pull request
В чужой репозиторий, будь то библиотека наподобие numpy или проект соседней лаборатории, напрямую отправлять коммиты нельзя. Стандартный механизм состоит из следующих шагов:
- Fork. Кнопкой на GitHub создаётся личная копия репозитория в аккаунте разработчика.
- Форк клонируется, создаётся ветка, фиксируется исправление.
- Ветка отправляется в форк.
- Открывается pull request (PR) — предложение включить коммиты в основной репозиторий, сопровождаемое описанием и дифом.
- Владельцы читают код, пишут замечания (code review), автор дополняет ветку новыми коммитами, и PR обновляется автоматически.
- Одобренный PR вливается в основной репозиторий.
По этой схеме работает весь open source и заметная часть открытой науки; исправление одной строки в документации scipy проходит тот же путь. Внутри своей группы обычно отправляют коммиты в общий репозиторий, однако правки всё равно оформляются ветками и PR ради ревью.
Хорошие практики
Коммиты следует делать атомарными, по одному логическому изменению на коммит. «Исправлена нормировка спектра» и «добавлен скрипт карты магнитного поля» — два коммита, а не один «изменил всякое». Атомарный коммит можно понять, откатить или перенести целиком, коммит со смешанными изменениями — нельзя. По той же причине желательно, чтобы проект в каждом коммите хотя бы запускался: тогда любая точка истории пригодна для поиска момента, в который возникла ошибка.
Не менее важно сообщение коммита. Слово fix не сообщает, что исправлено, где и почему; через полгода git log из fix, fix2 и wip превращается в длительные археологические раскопки. Диф и так покажет, что изменилось. Следует писать короткий заголовок с сутью изменения, не «поменял коэффициент», а «Увеличен шаг сетки: старый не помещался в память на кластере». По общепринятому соглашению заголовок укладывается в 50 символов и не превышает 72. Сообщение должно объяснять, зачем сделано изменение.
Git создан для текста и плохо работает с большими бинарными файлами. Каждый попавший в историю гигабайт останется в ней навсегда, и клонирование станет неприемлемо долгим. Сырые данные с установки, HDF5/ROOT-файлы, видео, обученные модели в репозиторий не помещают. Для версионирования больших файлов рядом с кодом существуют Git LFS (в git хранятся лишь ссылки, а содержимое находится на отдельном сервере) и DVC (Data Version Control, версионирующий данные и пайплайны обработки, а сами файлы размещающий в любом хранилище, будь то облако или сетевой диск группы). Пароли, токены и ключи фиксировать в репозитории недопустимо: удалить секрет из истории почти невозможно, а клоны коллег успевают разойтись.
Исправление ошибок
Зафиксированное в коммите почти невозможно потерять: Git скорее сохранит лишнее, чем удалит нужное. Ниже приведена справочная таблица по типовым ситуациям:
| Ситуация | Команда |
|---|---|
| Файл испорчен, требуется версия из последнего коммита | git restore file.py (незакоммиченные правки исчезнут!) |
| В индекс добавлено лишнее | git restore --staged file.py (убирает из индекса, правки останутся) |
| Опечатка в сообщении последнего коммита или забыт файл | git commit --amend (только пока не запушил) |
| Требуется «рассыпать» последний коммит обратно в правки | git reset --soft HEAD~1 (коммит исчез, изменения остались в индексе) |
| Удалить последний коммит вместе с изменениями | git reset --hard HEAD~1 (опасно, правки будут уничтожены) |
| Отменить уже запушенный коммит | git revert a3f2c17 (новый коммит с обратным изменением, история не переписывается) |
| Срочно нужна другая ветка, а правки не закончены | git stash прячет изменения, git stash pop возвращает |
| «Всё сломано, коммиты пропали» | git reflog, журнал последних положений HEAD; достаточно найти нужный хеш и вернуться командой git reset --hard <хеш> |
git reset --hard — одна из немногих команд, без предупреждения и необратимо уничтожающих незакоммиченную работу. Перед её выполнением необходимо вызвать git status и убедиться, что терять нечего. Историю переписывает и git rebase, переносящий коммиты ветки поверх другой так, как если бы они были сделаны там изначально: граф получается линейным и читаемым, однако перенесённые коммиты получают новые хеши, то есть прежних коммитов больше нет. Не следует переписывать историю (reset, --amend, rebase) в ветках, уже отправленных на сервер и полученных коллегами: им придётся разбираться с разошедшимся графом.
Полезные ссылки
- Введение в основы Git
- Создание алиасов команд
- Как просматривать историю изменений
- Один из подходов к созданию веток
- git rebase для начинающих
- Learn Git Branching, интерактивный тренажёр, в котором ветки, merge и rebase показаны на живом графе коммитов
- Oh Shit, Git!?!, короткие рецепты спасения из типичных ситуаций вида «всё сломано»
- Git Flight Rules, «лётные правила», большой сборник ответов, сгруппированных вокруг вопроса «что делать, если…»