109. Git для инженера: ветки, история, версии прошивки
Прошивка живёт годами: её правят, выпускают, откатывают, а через полгода приходит рекламация на плату с версией, которой уже никто не помнит. Git отвечает на три вопроса разом: что сейчас в приборе, что изменилось с прошлой версии и когда именно сломалось. Раздел разбирает не команды ради команд, а устройство: почему имя объекта — это его содержимое, как из истории получается строка версии внутри прошивки и как найти сломавший коммит за пять проверок вместо двадцати.
Опыты идут с настоящим git 2.43 в контейнере, а идентификаторы объектов рядом считает наш же код на C, который собирается и для F103, и для F407.
Как это называется в документации
Оригинал Перевод repository репозиторий: история проекта целиком working tree / index (staging area) рабочий каталог / индекс: что войдёт в коммит commit коммит: снимок дерева с автором и родителем blob / tree содержимое файла / каталог branch ветка: подвижная метка на коммите tag тег: неподвижная метка, обычно версия merge / merge conflict слияние / конфликт слияния fast-forward перемотка: слияние без нового коммита rebase перенос коммитов на другую основу git describeстрока версии из тега и числа коммитов dirty tree несохранённые правки в рабочем каталоге bisect двоичный поиск сломавшего коммита detached HEAD отсоединённая голова: не на ветке
109.1 Три места, а не одно
У git три хранилища, и половина недоразумений — от того, что их путают:
рабочий каталог (файлы на диске), индекс (что уже отобрано в следующий
коммит) и сама история. git add переносит из первого во второй,
git commit — из второго в третий. Отсюда понятны и ответы: git status
показывает, что где лежит, git diff — разницу между каталогом
и индексом, git diff --staged — между индексом и последним коммитом.
Для прошивки это важно ещё и потому, что в репозиторий не должно попасть
лишнее: каталоги сборки, .o, .axf, файлы среды. Правило простое:
в истории — только исходники и то, из чего они собираются, всё
порождаемое — в .gitignore.
109.2 Имя объекта — это его содержимое
Git хранит не «файлы с версиями», а объекты, и имя объекта — SHA-1 от его типа, длины и содержимого:
SHA-1("blob" + " " + длина + "\0" + содержимое)
Отсюда всё остальное. Два одинаковых файла в разных коммитах — один
объект. Пустой файл в любом репозитории мира имеет одно и то же имя
e69de29bb2d1d6434b8b29ae775ad8c2e48c5391. Изменился хоть один байт —
имя другое, и подменить содержимое незаметно нельзя.
Наш код считает это сам:
void git_object_id(const char *type, const void *data, unsigned n, uint8_t id[20])
{
char head[32];
unsigned k = (unsigned)strlen(type);
memcpy(head, type, k);
head[k++] = ' ';
k += put_dec(head + k, n);
head[k++] = 0; /* нулевой байт — часть заголовка */
sha1_t s;
sha1_init(&s);
sha1_update(&s, head, k);
sha1_update(&s, data, n);
sha1_final(&s, id);
}
Опыт: настоящий git и этот код посчитали одно и то же для пустого файла,
для README и для main.c — 8252973…. Дерево из одного файла — это
строка «100644 main.c», нулевой байт и 20 байт идентификатора
содержимого, всего 34 байта; его имя — 958ed88…, и снова совпало.
Коммит поверх этого — обычный текстовый объект: дерево, родитель, автор, дата, сообщение. Поэтому у коммита слияния два родителя, а у первого коммита — ни одного.
109.3 Ветки и слияние
Ветка — не копия, а метка: имя, указывающее на коммит, и оно двигается
при каждом новом коммите. Поэтому ветки в git дешёвые: git switch -c
fix — это одна строка в файле.
Слияние ищет общего предка и накладывает обе стороны. Если правки в разных местах, git справится сам. Если обе ветки правили одно место — конфликт, и git оставляет выбор человеку:
<<<<<<< HEAD
void fix(void) { }
=======
void feature(void) { }
>>>>>>> feature
В опыте так и вышло: обе ветки дописали в конец main.c, git сообщил
о конфликте в одном файле и вставил три строки-маркера. После
разрешения — обе строки на месте, и у коммита слияния два родителя.
Важно, что конфликт — это не поломка, а вопрос. Опасность в другом: разрешить его «по-быстрому», выкинув чужую правку. Поэтому после разрешения конфликта прошивку положено собрать и прогнать тесты — именно на этом месте ловятся пропавшие строки.
109.4 Версия прошивки из истории
Тег ставят на выпуск: git tag -a v1.2.0 -m "версия 1.2.0". Дальше
git describe --tags --dirty сам собирает строку версии:
| Состояние | Строка |
|---|---|
| ровно на теге | v1.2.0 |
| три коммита после тега | v1.2.0-3-g<хэш> |
| есть несохранённые правки | v1.2.0-3-g<хэш>-dirty |
Эту строку передают компилятору как макрос и печатают при старте
прошивки. Тогда на вопрос «что в приборе» отвечает сам прибор,
а -dirty честно говорит: собрано из непонятно чего, у такой прошивки
нет соответствия в истории.
109.5 Разбор версии на плате
Строку удобно не только печатать, но и разбирать: сравнить свою версию с присланной по радио, отказаться ставить старую прошивку. Разбор занимает несколько строк и работает без библиотек:
if (s[0] == '-' && s[1] >= '0' && s[1] <= '9') { /* «-3-g5b1f0c2»: после тега были коммиты */
if (!(s = get_num(s + 1, &v->ahead)) || s[0] != '-' || s[1] != 'g')
return 0;
Сравнивать версии как текст нельзя: «1.10.0» меньше «1.9.3» по алфавиту, хотя 1.10.0 новее 1.9.3. Сравнение идёт по числам:
const unsigned x[4] = { a->major, a->minor, a->patch, a->ahead };
const unsigned y[4] = { b->major, b->minor, b->patch, b->ahead };
for (unsigned i = 0; i < 4; i++)
if (x[i] != y[i])
return x[i] < y[i] ? -1 : 1;
Четвёртое число — коммиты после тега: сборка v1.2.0-3 новее, чем сам
v1.2.0. Но осмысленно это число только на одной ветке: v1.2.0-3 с двух
разных веток по нему «равны». Поэтому запрет отката на приборе сравнивает
версии выпусков (тегов), а сборки с -N-g… ставят только на стенде. Строки, которых быть не должно («v1.2», «v1.2.0-x»), разбор
отвергает, а не толкует наугад.
109.6 Что хранить, а что нет
- Исходники, скрипты сборки, файлы проекта — да.
- Пакеты и библиотеки — ссылкой на версию (
submodule, менеджер из раздела 115), а не копией. - Результаты сборки — нет:
.o,.axf,.hex, каталогиbuild/. Исключение — выпущенные образы, но им место в хранилище артефактов (раздел 110), а не в истории. - Секреты — никогда. Удалить их позже почти невозможно: коммит остаётся в истории и у всех, кто её забрал. Утёкший ключ считают скомпрометированным и меняют.
- Большие двоичные файлы (осциллограммы, картинки для дисплея) — осторожно: уже сжатые файлы (картинки, архивы, образы) разностями почти не сжимаются, каждая версия ложится целиком и раздувает репозиторий навсегда. Для них есть Git LFS: в истории остаётся ссылка, а сам файл лежит в отдельном хранилище.
109.7 Поиск сломавшего коммита за пять проверок
Самая полезная команда, о которой чаще всего не знают. Известно: месяц
назад собиралось, сейчас нет. Двадцать коммитов между — перебирать
вручную долго. git bisect делает двоичный поиск, а git bisect run
ещё и запускает проверку сам:
git bisect start HEAD "$FIRST" > /dev/null 2>&1
git bisect run sh -c 'gcc -fsyntax-only app.c' > /tmp/bisect.log 2>&1 || true
Опыт: 21 коммит, в тринадцатом потеряна точка с запятой. git bisect
run с настоящим компилятором нашёл ровно тот коммит 13 за 5 проверок
вместо двадцати — это ⌈log₂ 21⌉. Проверкой может быть что угодно, лишь бы
она возвращала ноль на исправном коммите: сборка, тест на компьютере,
прогон на плате.
Порядок проверок в опыте: коммит 10 собирается, 15 — нет, 12 — да, 14 — нет, 13 — нет. Каждый ответ отбрасывает половину оставшегося отрезка.
Правило то же, что у тестов: чем мельче коммиты, тем точнее ответ. Коммит «переписал драйвер» найдётся так же быстро, но сузит поиск до тысячи строк, а не до пяти.
109.8 Когда историю переписывают
git rebase переносит коммиты на новую основу, чтобы история читалась
прямой лентой. Это удобно до тех пор, пока коммиты никому не отданы:
перенос делает новые объекты с новыми именами, и у коллеги остаётся
старая ветка — вплоть до расхождения историй.
Правило простое: своё, не отправленное — правь сколько угодно; отправленное — только новыми коммитами. Исправление ошибки отдельным коммитом честнее: в истории видно, что было не так, и это ровно то, что ищут через полгода.
109.9 Как это выглядит в работе с прошивкой
- Ветка на задачу, короткая жизнь, слияние в основную после сборки и тестов (раздел 110).
- Тег на каждый выпуск, версия в прошивке — из
git describe. - В сообщении коммита — что и зачем; «поправил» через полгода бесполезно. Первая строка короткая, дальше подробности.
- Выпуск помечается тегом, а не веткой: ветка сдвинется, тег останется.
- Прошивка печатает версию при старте, и рекламация начинается не с «наверное, старая», а с точной строки.
109.10 Грабли
-dirty в версии выпущенной прошивки. Собрано из несохранённых
правок: такой прошивки нет в истории и повторить её нельзя. Собирать
выпуск только из чистого дерева (раздел 110 — в контейнере).
Версии сравниваются как текст. 1.10.0 оказывается «старше» 1.9.3. Сравнивать по числам.
Секрет в истории. Удаление файла новым коммитом не помогает: старый коммит на месте. Менять ключ.
Репозиторий распух. В истории двоичные файлы сборки. Их место — в хранилище артефактов.
Конфликт разрешён «по-быстрому». Чужая правка выкинута. После разрешения — сборка и тесты.
Переписана отправленная история. У коллег другая история той же ветки. Отправленное правят новыми коммитами.
Отсоединённая голова. Коммиты сделаны не на ветке и «пропали»:
они есть, но на них ничто не указывает. git switch -c имя на месте
или git reflog, чтобы найти.
109.11 Итоги
- Имя объекта — SHA-1 от типа, длины и содержимого; наш код на C и настоящий git считают его одинаково, пустой файл всегда e69de29bb2d1d6434b8b29ae775ad8c2e48c5391.
- Дерево из одного файла — 34 байта: режим, имя, ноль и 20 байт ID.
- Ветка — подвижная метка, тег — неподвижный; у коммита слияния два родителя.
- Версия прошивки — из
git describe:v1.2.0-3-g<хэш>, а-dirtyозначает, что повторить сборку нельзя. - Сравнение версий — по числам: 1.10.0 новее 1.9.3.
git bisect runнаходит сломавший коммит 13 из 21 за 5 проверок вместо перебора.
Полные исходники
git109.c — SHA-1 и имена объектов Git, разбор git describe и сравнение версий.
/* Раздел 109: Git для инженера — объекты и их имена, версия прошивки.
* SHA-1 по стандарту, ID объектов Git — blob, tree, commit — как их считает
* сам Git: SHA-1 от заголовка «тип длина\0» и содержимого. Разбор строки
* git describe в поля версии и сравнение версий по числам, а не по тексту.
* Переносимый C: собирается и для платы — прошивка может сама проверить
* хэш образа и сравнить свою версию с присланной (_verify/git109_host.c
* считает ID, которые проверка сверяет с настоящим git).
* Проверка — _verify/git109_check.py. */
#include <stdint.h>
#include <string.h>
/* ── SHA-1 ───────────────────────────────────────────────────────────── */
typedef struct {
uint32_t h[5];
uint64_t len; /* байт принято */
uint8_t buf[64];
unsigned used;
} sha1_t;
static uint32_t rol(uint32_t x, unsigned n)
{
return x << n | x >> (32u - n);
}
static void sha1_block(sha1_t *s, const uint8_t *p)
{
uint32_t w[80];
for (unsigned i = 0; i < 16; i++)
w[i] = (uint32_t)p[4 * i] << 24 | (uint32_t)p[4 * i + 1] << 16 | (uint32_t)p[4 * i + 2] << 8 | p[4 * i + 3];
for (unsigned i = 16; i < 80; i++)
w[i] = rol(w[i - 3] ^ w[i - 8] ^ w[i - 14] ^ w[i - 16], 1);
uint32_t a = s->h[0], b = s->h[1], c = s->h[2], d = s->h[3], e = s->h[4];
for (unsigned i = 0; i < 80; i++) {
uint32_t f, k;
if (i < 20) {
f = (b & c) | (~b & d);
k = 0x5A827999u;
} else if (i < 40) {
f = b ^ c ^ d;
k = 0x6ED9EBA1u;
} else if (i < 60) {
f = (b & c) | (b & d) | (c & d);
k = 0x8F1BBCDCu;
} else {
f = b ^ c ^ d;
k = 0xCA62C1D6u;
}
uint32_t t = rol(a, 5) + f + e + k + w[i];
e = d;
d = c;
c = rol(b, 30);
b = a;
a = t;
}
s->h[0] += a;
s->h[1] += b;
s->h[2] += c;
s->h[3] += d;
s->h[4] += e;
}
void sha1_init(sha1_t *s)
{
static const uint32_t h0[5] = { 0x67452301u, 0xEFCDAB89u, 0x98BADCFEu, 0x10325476u, 0xC3D2E1F0u };
memcpy(s->h, h0, sizeof h0);
s->len = 0;
s->used = 0;
}
void sha1_update(sha1_t *s, const void *data, unsigned n)
{
const uint8_t *p = data;
s->len += n;
while (n--) {
s->buf[s->used++] = *p++;
if (s->used == 64) {
sha1_block(s, s->buf);
s->used = 0;
}
}
}
void sha1_final(sha1_t *s, uint8_t out[20])
{
uint64_t bits = s->len * 8u;
static const uint8_t one = 0x80, zero = 0;
sha1_update(s, &one, 1); /* бит «1», затем нули до 56 байт в блоке */
while (s->used != 56)
sha1_update(s, &zero, 1);
uint8_t len[8];
for (unsigned i = 0; i < 8; i++)
len[i] = (uint8_t)(bits >> (56 - 8 * i)); /* длина в битах, старшим байтом вперёд */
sha1_update(s, len, 8);
for (unsigned i = 0; i < 20; i++)
out[i] = (uint8_t)(s->h[i / 4] >> (24 - 8 * (i % 4)));
}
/* ── ID объекта Git: SHA-1 от «тип длина\0» и содержимого ────────────── */
static unsigned put_dec(char *p, unsigned v)
{
char t[10];
unsigned n = 0, k = 0;
do
t[n++] = (char)('0' + v % 10u);
while (v /= 10u);
while (n)
p[k++] = t[--n];
return k;
}
void git_object_id(const char *type, const void *data, unsigned n, uint8_t id[20])
{
char head[32];
unsigned k = (unsigned)strlen(type);
memcpy(head, type, k);
head[k++] = ' ';
k += put_dec(head + k, n);
head[k++] = 0; /* нулевой байт — часть заголовка */
sha1_t s;
sha1_init(&s);
sha1_update(&s, head, k);
sha1_update(&s, data, n);
sha1_final(&s, id);
}
/* дерево из одного файла: «режим имя\0» и 20 байт ID его содержимого */
unsigned git_tree_one(uint8_t *t, const char *mode, const char *name, const uint8_t blob[20])
{
unsigned k = 0;
size_t m = strlen(mode), f = strlen(name);
memcpy(t, mode, m);
k += (unsigned)m;
t[k++] = ' ';
memcpy(t + k, name, f);
k += (unsigned)f;
t[k++] = 0;
memcpy(t + k, blob, 20);
return k + 20;
}
/* ── версия прошивки из git describe ─────────────────────────────────── */
typedef struct {
unsigned major, minor, patch; /* из тега vX.Y.Z */
unsigned ahead; /* коммитов после тега */
char hash[16]; /* короткий ID коммита */
int dirty; /* собрано из изменённого дерева */
} fw_version_t;
static const char *get_num(const char *s, unsigned *v)
{
if (*s < '0' || *s > '9')
return 0;
*v = 0;
while (*s >= '0' && *s <= '9')
*v = *v * 10u + (unsigned)(*s++ - '0');
return s;
}
/* «v1.2.0-3-g5b1f0c2-dirty» → поля; 1 — разобрано */
int fw_describe(const char *s, fw_version_t *v)
{
memset(v, 0, sizeof *v);
if (*s == 'v')
s++;
if (!(s = get_num(s, &v->major)) || *s++ != '.' || !(s = get_num(s, &v->minor)) || *s++ != '.'
|| !(s = get_num(s, &v->patch)))
return 0;
if (s[0] == '-' && s[1] >= '0' && s[1] <= '9') { /* «-3-g5b1f0c2»: после тега были коммиты */
if (!(s = get_num(s + 1, &v->ahead)) || s[0] != '-' || s[1] != 'g')
return 0;
s += 2;
unsigned k = 0;
while (((*s >= '0' && *s <= '9') || (*s >= 'a' && *s <= 'f')) && k < sizeof v->hash - 1)
v->hash[k++] = *s++;
v->hash[k] = 0;
}
if (strcmp(s, "-dirty") == 0)
v->dirty = 1;
else if (*s)
return 0;
return 1;
}
/* сравнение по числам: 1.10.0 новее 1.9.3, хотя как текст «1.10» меньше «1.9» */
int fw_cmp(const fw_version_t *a, const fw_version_t *b)
{
const unsigned x[4] = { a->major, a->minor, a->patch, a->ahead };
const unsigned y[4] = { b->major, b->minor, b->patch, b->ahead };
for (unsigned i = 0; i < 4; i++)
if (x[i] != y[i])
return x[i] < y[i] ? -1 : 1;
return 0;
}
Дальше: раздел 110 «CI/CD для прошивки» — та же история, но сборка и тесты запускаются сами, а на выходе — образ с версией из тега.