devops-курс Все курсы

✻ Урок 3.1 · Тема 3: Git и CI

Git: коммиты, история и ветки

⏱ 4 ч

Зачем это нужно

Ты правишь app.py, ломаешь его и не помнишь, как было раньше. Или коллега и ты правите один файл, и непонятно, чья версия «настоящая». Без специальной программы люди решают это копиями: app_final.py, app_final2.py, app_final_NEW_last.py. Через неделю никто не знает, какой файл рабочий и что менялось между ними.

Git (произносится «гит») решает это: он запоминает состояние всего проекта в нужные тебе моменты, и к любому из них можно вернуться. Историю можно читать как журнал изменений: кто, когда и зачем поправил каждую строку. На работе без git не живёт ничего: ни код, ни конфиги (файлы с настройками программ), ни Terraform (инструмент, который описывает серверы и сети текстовыми файлами, урок 7.1), ни манифесты Kubernetes (текстовые файлы, в которых описано, какие приложения запускать в кластере, урок 5.1). Всё это просто текст, а текст git хранит отлично.

Инцидент (incident) это сбой в работе сервиса, например «сайт упал». Половина таких разборов сводится к вопросу «кто это менял и когда», и отвечает на него команда git log (показать журнал изменений). А фраза «я потерял работу» чаще всего лечится через git reflog: это журнал твоих собственных перемещений по истории, в нём находится даже то, что ты случайно удалил. Научишься им пользоваться сегодня.

Шаг проекта: каталог ~/notes становится git-репозиторием (папкой, за которой git следит и в которой хранит историю) с .gitignore (список файлов, которые git должен не замечать, например пароли и мусор) и README.md (текстовая «визитка» проекта: что это и как запустить), первый коммит фиксирует «Заметки» v3.

Что нужно знать

Если чего-то из проекта не хватает (например, deploy/nginx/notes.conf из урока 2.5), это не мешает: git сохранит то, что есть.

Картина целиком

Представь научного сотрудника, который пишет диссертацию. У него есть:

  • рабочий стол, на котором лежит текст, и он его правит как угодно;
  • папка «на сдачу», куда он перекладывает те страницы, которые готовы попасть в очередную версию;
  • архив, куда раз за разом кладут запечатанную версию работы с датой и подписью: «глава 2 готова». Из архива ничего не выбрасывают, поэтому можно достать любую прошлую версию.

Git устроен так же. Только вместо стола, папки и архива у него три области: рабочий каталог, индекс и история. А запечатанная версия называется коммит (commit): сохранённый снимок всего проекта с подписью «что и зачем изменил». У каждого коммита есть имя-хеш (hash): набор букв и цифр вроде 479bef3, который git вычисляет из содержимого.

flowchart LR
    W["Рабочий каталог<br>ты правишь файлы"] -->|"git add"| I["Индекс<br>что войдёт в коммит"]
    I -->|"git commit"| H[("История в .git<br>коммиты с хешами")]

Цепочка коммитов: каждый коммит помнит предыдущий, а ветка и HEAD только указывают на место.

flowchart LR
    A((A)) --> B((B)) --> C((C)) --> D((D))
    M["ветка main<br>закладка на D"] -.-> D
    HD["HEAD<br>я сейчас здесь"] -.-> M

Ветка (branch) на схеме это подвижная закладка на одном из коммитов: по ней git знает, где кончается твоя цепочка. HEAD - указатель «я сейчас здесь»: на какой ветке ты работаешь. Подробно оба понятия разберём ниже.

Дальше по уроку: как файл проходит путь слева направо, что такое коммит на самом деле (это не «разница»), как ветки дают работать над задачами параллельно и как безопасно отменять сделанное. В конце ты превратишь свой проект «Заметки» в репозиторий.

Теория

Зачем нужна система контроля версий

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

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

Идеи тут три:

  1. Снимки. Git фиксирует не «что изменилось», а состояние всего проекта в момент коммита, как фотографию.
  2. Распределённость. Полная история лежит у каждого, кто работает с проектом, а не в одном месте. Поэтому git log, откат и ветки работают без сети. Как обмениваться историей с другими, разберём в уроке 3.2.
  3. Целостность. Каждое состояние получает имя, вычисленное из содержимого (хеш, об этом ниже). Незаметно подменить старую версию нельзя.

Без git у тебя app.py, app_old.py и app_before_slow.py. С git у тебя один app.py и журнал:

b2049a7 docs: добавить /slow
479bef3 docs: перечислить ручки API

Каждая строка это сохранённая версия. Чтобы посмотреть проект таким, каким он был на строке ниже, не нужны копии: достаточно назвать её хеш (479bef3).

Прикинь сам: ты за день правил app.py двадцать раз и сделал пять коммитов. Сколько версий файла сможешь достать завтра без git и сколько с git?

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

Осторожно: Git и GitHub это разные вещи. Git это программа на твоём компьютере, которая ведёт историю. GitHub это сайт, где можно хранить копии таких историй и работать над ними вместе. Пользоваться git можно без GitHub, и весь этот урок обходится без него.

Главное: git запоминает снимки проекта в моменты, которые ты выбираешь, и вся история лежит на твоём диске, а не на сервере.

Проверь понимание: ты отключил интернет. Можно ли посмотреть историю проекта и вернуться к вчерашней версии файла?

Ответ

Да. Вся история лежит на твоём диске, в скрытом каталоге .git внутри проекта. Сеть нужна только для обмена с другими людьми.

Где эта история физически хранится и как она связана с папкой проекта? Посмотрим внутрь.

Репозиторий и каталог .git

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

Проект это шкаф с бумагами, а .git потайное отделение внутри этого же шкафа, где лежит журнал версий. Унесёшь шкаф целиком, и журнал уйдёт вместе с ним. Аналогия ломается в одном месте: потайное отделение никто не открывает вручную, всё делается командами.

Репозиторий (repository, сокращённо «репо») это каталог проекта вместе с историей. Он становится репозиторием после команды git init: она создаёт внутри каталога скрытый подкаталог .git. Скрытый значит, что имя начинается с точки, и обычный ls его не показывает (нужен ls -A). Удалишь .git, и проект превратится в обычную папку без истории. Файлы при этом останутся.

Реальное содержимое .git в свежем репозитории после двух коммитов:

$ ls -A .git
COMMIT_EDITMSG
HEAD
branches
config
description
hooks
index
info
logs
objects
refs
  • HEAD: указатель на то, где ты сейчас (что это, объясним ниже);
  • objects: сама база данных: там лежит всё содержимое всех коммитов;
  • refs: имена веток и тегов;
  • index: индекс, то есть «папка на сдачу»;
  • config: настройки этого репозитория;
  • logs: журнал перемещений HEAD (это основа reflog, тоже ниже);
  • hooks: скрипты, которые git запускает в определённые моменты (нам пока не нужны).

Остальное (COMMIT_EDITMSG, branches, description, info) служебное и для нас неважно. В Ubuntu 26.04 (git 2.53) каталога branches в списке нет: он давно не используется, и новые версии его не создают.

Прикинь сам: в .git лежат HEAD, objects, refs, index. Где из них хранится само содержимое всех коммитов?

В objects: это база данных, куда git кладёт содержимое файлов, деревья и сами коммиты. refs хранит только имена веток и тегов, а HEAD указывает, где ты стоишь.

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

Главное: репозиторий это папка проекта плюс скрытый .git с историей: удалишь .git, пропадёт история, а файлы останутся.

Проверь понимание: ты случайно выполнил rm -rf .git в каталоге проекта. Что произошло с файлами проекта и с историей?

Ответ

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

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

Три области: рабочий каталог, индекс, история

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

Отправка посылки. Вещи лежат по комнатам (рабочий каталог). Ты выбираешь, что положить в коробку (индекс). Когда коробка собрана, её заклеивают, подписывают и отправляют на склад (коммит в историю). Ты можешь положить в коробку не всё, что лежит на столе, и вынуть лишнее, пока она не заклеена. Аналогия ломается на том, что в git вещи из комнат не пропадают: в коробку кладётся копия.

Три места, в которых файл может находиться:

stateDiagram-v2
    state "untracked<br>git о файле не знает" as U
    state "modified<br>правки только в каталоге" as M
    state "staged<br>правки в индексе" as S
    state "committed<br>снимок в истории" as C
    [*] --> U: создал файл
    U --> S: git add
    M --> S: git add
    S --> C: git commit
    C --> M: правишь файл
    S --> M: git restore --staged
  • Рабочий каталог (working tree): файлы, которые ты видишь и правишь в редакторе.
  • Индекс (index, ещё «staging area», «область подготовки»): список того, что попадёт в следующий коммит. Физически это файл .git/index.
  • История: коммиты (commit) в каталоге .git.

Файл при этом бывает в одном из состояний:

Состояние Что значит Как получить
untracked (неотслеживаемый) git о файле не знает создать новый файл
modified (изменён) git знает файл, но правки не в индексе править отслеживаемый файл
staged (в индексе) правки попадут в следующий коммит git add файл
committed (зафиксирован) правки в истории git commit

Слово отслеживаемый (tracked) значит «файл уже есть в последнем коммите или в индексе». Остальные файлы в каталоге для git чужие.

Реальный путь файла api.txt. Читай вывод сверху вниз: команда, потом её результат.

$ printf 'GET /healthz\nGET /headers\n' > api.txt
$ git status --short
?? api.txt
$ git add api.txt
$ git status --short
A  api.txt
$ git commit -m "docs: перечислить ручки API"
[main (root-commit) 479bef3] docs: перечислить ручки API
 1 file changed, 2 insertions(+)
 create mode 100644 api.txt
$ echo 'GET /slow' >> api.txt
$ git status --short
 M api.txt

Команда git status рассказывает, что в какой области. В короткой форме (--short) каждая строка состоит из двух колонок и имени файла. Левая колонка это индекс против последнего коммита, правая это рабочий каталог против индекса:

| Строка | Читается как | |—|—| | ?? api.txt | неотслеживаемый файл | | A api.txt | (A, потом пробел) добавлен в индекс, в рабочем каталоге после этого не менялся | | ` M api.txt | (пробел, потом M) изменён, но не в индексе | | M api.txt | изменён и в индексе | | MM api.txt` | изменён в индексе, а потом ещё раз в рабочем каталоге |

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

Прикинь сам: ты изменил api.txt, сделал git add api.txt, а потом дописал в файл ещё строку. Что покажет git status --short?

MM api.txt: левая M говорит, что в индексе лежит одна версия файла, правая M говорит, что в каталоге файл уже новее индекса. В коммит уйдёт только то, что было на момент add.

Осторожно: git add «добавляет файл в git навсегда». Нет: add кладёт в индекс ту версию файла, которая была в момент команды. Если после add правишь файл дальше, эти новые правки в индекс не попадут, пока не сделаешь add снова (в выводе появится MM).

Главное: файл идёт по пути «каталог, индекс, история», а git add и git commit двигают его на один шаг каждый.

Проверь понимание: ты изменил два файла, а git add сделал только для одного. Что попадёт в git commit?

Ответ

Только проиндексированный файл, в том виде, в каком он был в момент add. Второй останется изменённым в рабочем каталоге ( M). Если ты правил первый файл уже после add, эти правки тоже не попадут в коммит.

Мы знаем, как файл попадает в историю. Но что именно лежит в коммите: правка или что-то другое?

Коммит: снимок, а не «разница»

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

Фотография всей комнаты, с подписью на обороте: кто снимал, когда, что изменилось, и номер предыдущей фотографии. Не «список перестановок», а именно снимок. Аналогия ломается на экономии: git не хранит десять копий одного и того же, одинаковое содержимое лежит один раз.

Коммит (commit) содержит:

  • ссылку на дерево (tree): снимок всех каталогов и файлов проекта;
  • ссылку на родителя (parent): предыдущий коммит (у первого родителя нет, у слияния их два: такой коммит называют merge commit, коммит слияния, о нём ниже);
  • автора и время, сообщение.

Зачем это знать на практике: благодаря именам-хешам один и тот же файл хранится один раз, сколько бы коммитов его ни содержали, а по хешу видно, что история не подменена. Как это устроено. Всё в git хранится как объекты (objects) трёх видов: blob (от binary large object, «двоичный большой объект»: просто содержимое одного файла, без имени), tree (список: «этот файл в этом состоянии, этот подкаталог в таком-то») и commit. Каждый объект получает имя, вычисленное из его содержимого функцией хеширования (hash). Хеш-функция превращает любые данные в короткий отпечаток фиксированной длины. Если данные изменятся хоть на один символ, отпечаток будет совсем другим. Git использует SHA-1: отпечаток из 40 шестнадцатеричных цифр (0-9 и a-f). Обычно показывают первые 7, этого хватает, чтобы имя было однозначным.

Схема цепочки из двух коммитов (реальные значения ниже):

flowchart TD
    C2["коммит b2049a7<br>docs: добавить /slow"] -->|"parent"| C1["коммит 479bef3<br>docs: перечислить ручки API"]
    C2 --> T2["tree 021fc7a"] --> B2["api.txt = blob 3aa64fc<br>3 строки"]
    C1 --> T1["tree 3c0961a"] --> B1["api.txt = blob 8a7761e<br>2 строки"]

Реальные объекты из практики. Запросим у git содержимое последнего коммита (cat-file -p значит «напечатай объект понятным образом»; HEAD пока читай как «последний коммит», объясним ниже):

$ git cat-file -p HEAD
tree 021fc7a011e0bc0e4ec625bd9e72987edd512dbc
parent 479bef3f4f8fde6fc58a35718d51d7400c2f7735
author Ivan Petrov <ivan@example.com> 1790770357 +0000
committer Ivan Petrov <ivan@example.com> 1790770357 +0000

docs: добавить /slow
  • tree: хеш снимка проекта;
  • parent: хеш предыдущего коммита;
  • author и committer: кто написал и кто записал (обычно один человек), число 1790770357 это время в секундах с 1 января 1970 года (так компьютеры хранят время), +0000 часовой пояс;
  • после пустой строки: сообщение.

Теперь заглянем в дерево и в файл:

$ git cat-file -p 'HEAD^{tree}'
100644 blob 3aa64fc35f17ab606070b5c21562309c4486c885	api.txt
$ git cat-file -p 3aa64fc
GET /healthz
GET /headers
GET /slow

Дерево говорит: в снимке один файл api.txt с правами 100644 (обычный файл), его содержимое лежит в blob с хешем 3aa64fc.... Второй командой мы прочитали этот blob.

Как хеш получается из содержимого, можно проверить руками. Для blob хешируется заголовок blob <длина>, нулевой байт и содержимое. Возьмём файл из одного слова hello с переводом строки (6 байт):

$ printf 'blob 6\0hello\n' | sha1sum
ce013625030ba8dba906f756967f9e9ca394464a  -
$ echo hello | git hash-object --stdin
ce013625030ba8dba906f756967f9e9ca394464a

Обычная утилита sha1sum и git выдали одно и то же: git не колдует, он считает SHA-1 от заголовка и содержимого. Отсюда же два следствия. Первое: одинаковое содержимое у любого человека даёт одинаковый хеш (blob 3aa64fc и tree 021fc7a у тебя совпадут с моими, а хеши коммитов нет: они включают время и автора). Второе: хеш коммита включает хеш родителя, а тот включает хеш своего родителя, и так далее. Поэтому незаметно изменить старый коммит нельзя: поменяется его хеш, а с ним хеши всех потомков.

Прикинь сам: в проекте сто файлов, ты поменял один и сделал коммит. Сколько файлов в снимке нового коммита и сколько новых blob’ов создал git?

В снимке все сто файлов, но новый blob один: для остальных девяноста девяти в дереве записаны хеши уже существующих blob’ов. Поэтому снимки дёшевы, а «разницу» git вычисляет на лету.

Тут часто путают так. Что коммит это «разница с прошлым разом». Нет, это снимок всего проекта. Разницу git вычисляет на лету, сравнивая два снимка (её показывает git diff). Коммит не «содержит правку файла»: он содержит ссылку на дерево, а разницу видно только в сравнении с родителем.

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

Проверь понимание: ты поправил опечатку в сообщении коммита трёхдневной давности. Изменится ли хеш этого коммита? Хеши коммитов после него?

Ответ

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

Снимки есть, а как увидеть, чем один отличается от другого? Для этого нужны diff и log.

Как читать git diff и git log

Три из четырёх команд, которые ты будешь запускать чаще всего, это status, diff и log. Без навыка чтения их вывода git превращается в набор заклинаний.

diff это редакторская правка на полях: красным вычеркнуто, зелёным вписано. log это оглавление книги версий.

git diff сравнивает две версии и печатает разницу в формате unified diff. Есть три частых сравнения:

Команда Сравнивает
git diff рабочий каталог с индексом («что я поправил, но ещё не добавил»)
git diff --staged индекс с последним коммитом («что уйдёт в следующий коммит»)
git diff <A> <B> два коммита

Реальный вывод после добавления строки GET /slow:

$ git diff
diff --git a/api.txt b/api.txt
index 8a7761e..3aa64fc 100644
--- a/api.txt
+++ b/api.txt
@@ -1,2 +1,3 @@
 GET /healthz
 GET /headers
+GET /slow
  • diff --git a/api.txt b/api.txt: сравниваются две версии файла api.txt (a/ старая, b/ новая);
  • index 8a7761e..3aa64fc 100644: хеши старого и нового содержимого (это blob’ы из прошлого раздела) и права файла;
  • --- a/api.txt и +++ b/api.txt: «было» и «стало»;
  • @@ -1,2 +1,3 @@: заголовок блока правок (hunk): в старой версии показаны строки с 1-й, две штуки; в новой с 1-й, три штуки;
  • строки с пробелом в начале: без изменений (контекст), с +: добавлено, с -: удалено.

git log показывает историю от новых коммитов к старым. У него много форм:

$ git log --oneline
b2049a7 docs: добавить /slow
479bef3 docs: перечислить ручки API
$ git log --stat --oneline
b2049a7 docs: добавить /slow
 api.txt | 1 +
 1 file changed, 1 insertion(+)
479bef3 docs: перечислить ручки API
 api.txt | 2 ++
 1 file changed, 2 insertions(+)

--oneline это по строке на коммит (короткий хеш и заголовок), --stat добавляет, сколько строк в каких файлах изменилось. Через -- можно ограничить историю одним файлом: git log --oneline -- api.txt. В обычном терминале длинный вывод git log открывается в просмотрщике less: листай стрелками, выйти клавишей q.

Прикинь сам: в заголовке блока правок написано @@ -10,3 +10,5 @@, и в блоке нет строк с -. Сколько строк добавили?

Две. В старой версии блок занимал 3 строки (с 10-й), в новой 5 строк, а удалений нет, значит все лишние пять минус три строки добавлены.

Главное: git diff показывает разницу между двумя состояниями (каталог и индекс, индекс и коммит, два коммита), а git log перечисляет коммиты от новых к старым.

Проверь понимание: после git add api.txt команда git diff ничего не печатает. Почему?

Ответ

git diff сравнивает рабочий каталог с индексом. После add в индексе лежит тот же самый файл, что в рабочем каталоге, и разницы нет. Разница есть между индексом и последним коммитом, её показывает git diff --staged.

Читать историю мы научились. Теперь вопрос, как вести две линии истории параллельно: нужны ветки.

Ветка, HEAD и отсоединённый HEAD

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

Закладка в книге. История это страницы (коммиты), ветка это закладка, воткнутая в последнюю страницу линии. Когда пишешь новую страницу, закладка переезжает на неё. HEAD это твой палец: он лежит на закладке, то есть «читаю отсюда». Аналогия ломается на том, что у закладки в git есть имя, а страницы можно продолжать в две стороны с одной и той же страницы.

Ветка (branch) это просто файл, в котором записан хеш одного коммита. Создание ветки стоит копейки: записывается 41 байт (40 символов хеша и перенос строки). Файлы проекта при этом не копируются. HEAD это тоже файл, и в нём записано, на какой ветке ты стоишь. Новый коммит получает родителем текущий, и ветка, на которой стоит HEAD, сдвигается на него.

flowchart LR
    subgraph before["до git commit"]
        A1((A)) --> B1((B)) --> C1((C))
        M1["main"] -.-> C1
        H1["HEAD"] -.-> M1
    end
    subgraph after["после git commit"]
        A2((A)) --> B2((B)) --> C2((C)) --> D2((D))
        M2["main сдвинулась"] -.-> D2
        H2["HEAD"] -.-> M2
    end

Если HEAD указывает прямо на коммит, а не на ветку, это отсоединённый HEAD (detached HEAD). Так бывает, если переключиться на конкретный коммит (git switch --detach <хеш>). Смотреть историю в таком состоянии можно, коммитить тоже, но у новых коммитов нет ветки, и после переключения на другую ветку никто на них не указывает. Спасение: пока помнишь хеш, создать ветку git switch -c имя <хеш>.

Реальное содержимое файлов. Мы на main, создаём ветку feature/slow:

$ git switch -c feature/slow
Switched to a new branch 'feature/slow'
$ cat .git/HEAD
ref: refs/heads/feature/slow
$ cat .git/refs/heads/feature/slow
b2049a7ef4033c629c5d0ba391dbbb31f974a49f
  • git switch переключает на ветку, ключ -c (create) создаёт её и сразу переключает;
  • .git/HEAD говорит: «я на ветке feature/slow»;
  • в файле ветки записан хеш коммита b2049a7: тот же, где была main. Отдельных «копий проекта» нет.

В отсоединённом состоянии HEAD не ссылается на ветку, а содержит сам хеш:

$ git switch --detach HEAD~1
HEAD is now at ee79ebc docs: добавить README
$ cat .git/HEAD
ee79ebcd453ffb865d2d5ae536295cdfe1d3aaab
$ git status
HEAD detached at ee79ebc

Запись HEAD~1 значит «родитель текущего коммита», HEAD~2 «родитель родителя». Тильда со счётчиком отсчитывает шаги назад по цепочке.

Прикинь сам: в проекте 5000 файлов. Сколько места занимает git branch feature?

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

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

Главное: ветка это подвижная закладка на коммит, HEAD показывает, на какой ветке ты стоишь, а новый коммит двигает именно эту ветку.

Проверь понимание: что делает команда git branch feature, когда ты стоишь на main?

Ответ

Создаёт новый указатель feature на тот же коммит, что и main. Файлы не копируются, и ты остаёшься на main: HEAD не сдвигается. Перейти можно командой git switch feature, а создать и перейти одной командой git switch -c feature.

Работа в ветке закончена. Как вернуть её результат в основную линию?

Слияние: fast-forward и merge commit

Работа в ветке закончена, и её надо вернуть в основную линию. Для этого ветки объединяют, это называется слияние (merge).

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

git merge ветка вливает ветку в ту, на которой ты стоишь. Возможны два случая:

Случай 1: fast-forward. main не уходила вперёд, пока ты работал в feature:

flowchart LR
    subgraph before["до слияния"]
        A1((A)) --> B1((B)) --> C1((C)) --> D1((D)) --> E1((E))
        M1["main на C"] -.-> C1
        F1["feature на E"] -.-> E1
    end
    subgraph after["после git merge feature"]
        A2((A)) --> B2((B)) --> C2((C)) --> D2((D)) --> E2((E))
        M2["main и feature на E"] -.-> E2
    end

Случай 2: ветки разошлись, нужен merge commit с двумя родителями:

flowchart LR
    A((A)) --> B((B)) --> C((C)) --> F((F)) --> M((M))
    C --> D((D)) --> E((E)) --> M
    MN["main на M"] -.-> M

На схемах стрелка идёт от старого коммита к новому. У коммита M два входа: от F (линия main) и от E (линия feature), это и есть два родителя.

  • Fast-forward: git просто двигает указатель ветки вперёд. Новых коммитов не появляется.
  • Merge commit: git создаёт новый коммит с двумя родителями (по одному от каждой линии) и сообщением «Merge branch …». В git log --graph это ромб.
  • Конфликт возникает, если обе линии изменили одни и те же строки одного файла: git не знает, чья версия верна, и просит решить человека. Разбор конфликтов, а также rebase (перекладывание коммитов), в следующем уроке.

Реальный вывод. Сначала fast-forward: main не менялась, пока мы работали в feature/slow.

$ git merge feature/slow
Updating b2049a7..20e28d4
Fast-forward
 slow.txt | 2 ++
 1 file changed, 2 insertions(+)
 create mode 100644 slow.txt

Строка Updating b2049a7..20e28d4 показывает, что указатель main переехал с b2049a7 на 20e28d4. Нового коммита не появилось.

Теперь ветки разошлись: в feature/error и в main по своему коммиту. Граф после слияния (* это коммит, линии показывают родителей):

$ git log --oneline --graph
*   ff61334 Merge branch 'feature/error'
|\  
| * f5de343 feat: описать /error
* | ee79ebc docs: добавить README
|/  
* 20e28d4 docs: указать предел /slow

Коммит ff61334 это merge commit, у него два родителя: ee79ebc (линия main) и f5de343 (линия feature/error). Убедимся: git cat-file -p HEAD печатает две строки parent.

После слияния ветку можно удалить: git branch -d feature/error. Удаляется только указатель («закладка»), а коммиты остаются в истории, потому что на них теперь ссылается main. Флаг -d (маленькая) безопасный: он откажется удалять ветку, коммиты которой нигде больше не упомянуты. Флаг -D (большая) удаляет принудительно.

Прикинь сам: от общего коммита в main появился один новый коммит, в feature тоже один. Сколько коммитов добавит git merge feature и что будет, если main не менялась?

Один: merge commit с двумя родителями. Если main не менялась, коммитов не добавится вообще, git только передвинет указатель (fast-forward).

Главное: слияние либо просто двигает указатель (fast-forward), либо создаёт merge commit с двумя родителями, когда линии разошлись.

Проверь понимание: сколько родителей у коммита, созданного git merge, если main за время работы ветки не менялась? А если менялась?

Ответ

Если main не менялась, слияние fast-forward и нового коммита нет вообще (только сдвиг указателя). Если менялась, создаётся merge commit с двумя родителями.

Объединять мы умеем. Но ошибки неизбежны: как откатывать сделанное и не сломать чужую работу?

Отмена: restore, revert и reset

Ошибки неизбежны: испортил файл, закоммитил лишнее, закоммитил не туда. Нужно уметь откатывать на разных уровнях. И критично знать, какие откаты безопасны, а какие переписывают историю.

Ошибка в письме, которое ты пишешь. Зачеркнуть строку в черновике (restore). Отправить вдогонку письмо «прошу игнорировать предыдущее» (revert). Или вырвать страницы из журнала исходящих, как будто их не было (reset). Третье допустимо, пока никто чужой журнал не читал.

Три команды разного уровня:

Команда Что делает Меняет историю?
git restore файл возвращает файл в рабочем каталоге таким, каким он в индексе нет
git restore --staged файл вынимает файл из индекса (правки остаются в файле) нет
git revert хеш создаёт новый коммит, отменяющий указанный нет, история только растёт
git reset режим хеш двигает текущую ветку на другой коммит да, коммиты «выпадают»

reset имеет три режима, они различаются тем, что кроме ветки трогают:

               ветка   индекс   рабочий каталог
--soft          да      нет         нет         (правки остаются в индексе)
--mixed         да      да          нет         (по умолчанию; правки остаются в файлах)
--hard          да      да          да          (правки пропадают)

Проще всего запомнить по глубине: --soft откатывает только «сам коммит», --mixed ещё и «add», --hard ещё и правки в файлах. Поэтому --hard опасен: незакоммиченное после него не вернуть ничем, потому что оно нигде не записано.

Главное правило выбора: то, что уже увидели другие, отменяют через revert, потому что он не переписывает историю. reset подходит только для локальных, никому не отправленных коммитов.

Реальный вывод. Коммит с ошибкой (лишняя ручка /wrong) и его отмена через revert:

$ git commit -am "docs: случайная ручка"
[main fa787b5] docs: случайная ручка
$ git revert --no-edit HEAD
[main 971f3e7] Revert "docs: случайная ручка"
 1 file changed, 1 deletion(-)
$ git log --oneline -3
971f3e7 Revert "docs: случайная ручка"
fa787b5 docs: случайная ручка
ff61334 Merge branch 'feature/error'

Ошибочный коммит fa787b5 остался в истории, а над ним появился новый 971f3e7, который убрал строку. --no-edit значит «не открывай редактор для сообщения, оставь стандартное». Файл стал таким, как до ошибки, история честно показывает и ошибку, и её исправление.

А теперь reset --soft на «важной ручке»:

$ git reset --soft HEAD~1
$ git status --short
M  api.txt

Коммит пропал из истории, но правка GET /important осталась в индексе (M в левой колонке). reset --mixed вынул бы её из индекса ( M), reset --hard удалил бы и её.

Прикинь сам: в истории 10 коммитов, последний ошибочный и уже у коллег. Сколько коммитов будет после git revert HEAD и после git reset --hard HEAD~1?

После revert 11: добавится коммит с обратной правкой, у коллег история продолжится. После reset --hard HEAD~1 9, но у коллег останется десятый, и расхождение придётся разбирать. Для общего коммита выбирай revert.

Тут часто путают так. «git reset --hard уничтожает коммиты». Нет: он двигает ветку и стирает незакоммиченные правки в файлах, а сами коммиты остаются в базе (найти их поможет reflog, следующий раздел). Незакоммиченное же исчезает безвозвратно.

Главное: restore отменяет правки в файлах, revert отменяет коммит новым коммитом, reset двигает ветку назад и годится только для не отправленной работы.

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

Ответ

revert. Он не переписывает историю, а добавляет новый коммит с обратными изменениями, поэтому у коллег история продолжается без расхождений. После reset у них остались бы коммиты, которых у тебя нет.

А если reset --hard сделан зря и нужные коммиты исчезли? Для этого есть журнал перемещений.

reflog: страховка от собственных ошибок

После reset --hard коммит исчез из git log, и кажется, что работа потеряна. Нужно место, где записано, куда указывал HEAD раньше.

Журнал входа в офис. Даже если сотрудник вышел и его нет в комнате, в журнале записано, когда он входил и выходил.

git reflog показывает журнал перемещений HEAD: каждый коммит, переключение, reset, слияние. Записи нумеруются HEAD@{0} (последняя), HEAD@{1} (предыдущая) и так далее. Коммит, выпавший из ветки, физически остаётся в базе .git/objects, пока его не удалит сборка мусора (git gc, запускается git сам и редко). Записи в журнале хранятся по умолчанию около 90 дней для достижимых коммитов и 30 дней для недостижимых. Значит, времени на спасение обычно достаточно. Reflog локальный: на другой машине у тебя его нет, и он не отправляется вместе с проектом.

Реальный вывод. Мы закоммитили «важную ручку» и потеряли её reset --hard:

$ git reset --hard HEAD~1
HEAD is now at 971f3e7 Revert "docs: случайная ручка"
$ git reflog -4
971f3e7 HEAD@{0}: reset: moving to HEAD~1
6c04595 HEAD@{1}: commit: docs: важная ручка
971f3e7 HEAD@{2}: reset: moving to HEAD
971f3e7 HEAD@{3}: reset: moving to HEAD
$ git reset --hard 'HEAD@{1}'
HEAD is now at 6c04595 docs: важная ручка

Читаем: HEAD@{0} это сам reset (мы двинулись на 971f3e7), а HEAD@{1} это то, что было до него: коммит 6c04595. Команда reset --hard 'HEAD@{1}' вернула ветку туда. Кавычки вокруг HEAD@{1} защищают фигурные скобки от оболочки: в bash они обычно не мешают, но привычка кавычек надёжна. Флаг -4 ограничивает вывод четырьмя записями.

Есть и быстрый способ. Сразу после reset git запоминает прошлое положение ветки в ORIG_HEAD, так что вернуть можно и командой git reset --hard ORIG_HEAD.

Прикинь сам: ты сделал git reset --hard HEAD~2. Какая запись в git reflog укажет, где ветка была до reset?

HEAD@{1}: запись HEAD@{0} это сам reset, а предыдущая запись хранит коммит, на котором ты стоял до него. Команда git reset --hard 'HEAD@{1}' возвращает всё назад.

Осторожно: reflog хранит правки файлов. Нет: он хранит только положения HEAD, то есть закоммиченное. Если правка не была ни в одном коммите, reflog о ней не знает. Отсюда практическое правило: чтобы можно было спасти работу, коммить её (хотя бы временным коммитом).

Главное: reflog хранит все перемещения HEAD на твоём компьютере, поэтому закоммиченную работу почти всегда можно вернуть.

Проверь понимание: ты сделал git reset --hard HEAD~3, и коммиты исчезли. Через неделю они ещё восстановимы?

Ответ

Да, скорее всего. Записи о них лежат в git reflog (30 дней и больше), а сами объекты в базе, пока их не удалила сборка мусора. Найди хеш до reset в reflog и создай на нём ветку: git branch rescue <хеш>. А вот незакоммиченные правки из этих коммитов это не касается: они не в коммитах.

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

stash, bisect, rm: три инструмента поменьше

git stash откладывает незакоммиченные правки «в карман» и возвращает рабочий каталог к чистому состоянию. Зачем: срочно нужно переключиться на другую ветку, а закоммитить полуготовую работу жалко. Вернуть правки можно командой git stash pop. Отложенное лежит в списке git stash list. Реальный вывод:

$ git stash
Saved working directory and index state WIP on main: 6c04595 docs: важная ручка
$ git stash list
stash@{0}: WIP on main: 6c04595 docs: важная ручка
$ git stash pop
On branch main
Changes not staged for commit:
	modified:   api.txt
Dropped refs/stash@{0} (b43248072f5e0518be537e9dbce96f60897a49f0)

Новые (неотслеживаемые) файлы stash по умолчанию не берёт, для них добавляют -u. Аналогия: убрать бумаги со стола в ящик, чтобы освободить место, и потом достать. Ловушка: про стеш легко забыть, и он лежит месяцами.

git bisect ищет коммит, который сломал код, двоичным поиском: берёт середину диапазона между «хорошим» и «плохим» коммитом, ты проверяешь её, и половина диапазона отбрасывается. Так для 1000 коммитов нужно примерно 10 проверок (2 в десятой степени это 1024), для 16 коммитов 4. Аналогия: угадываешь число от 1 до 100, и тебе каждый раз говорят «больше» или «меньше»: за 7 попыток гарантированно найдёшь. Проверку можно отдать скрипту: git bisect run скрипт, а скрипт сообщает результат кодом возврата (0 значит «хорошо», 1-124 и 126-127… строго говоря, 1-127 кроме 125 значит «плохо», 125 значит «нельзя проверить, пропусти»). Такой же код возврата $? ты видел в уроке 1.2.

git rm файл удаляет отслеживаемый файл и с диска, и из индекса (готовит удаление к коммиту). git rm --cached файл убирает файл только из git, а на диске оставляет: это нужно, когда файл закоммитили зря, но он нужен на диске (например, с секретом). Есть и git mv старое новое: переименование с сохранением истории.

Прикинь сам: между последним «хорошим» и первым «плохим» коммитом лежат 200 коммитов. Сколько проверок понадобится bisect?

Не больше восьми: 2 в седьмой степени это 128, а это меньше 200, а 2 в восьмой это 256. Каждая проверка отсекает половину диапазона.

Главное: stash убирает незакоммиченные правки в карман, bisect двоичным поиском находит сломавший коммит, rm --cached убирает файл из git, не трогая диск.

Проверь понимание: ты закоммитил файл .env и хочешь убрать его из git, но оставить на диске. Какая команда?

Ответ

git rm --cached .env, затем коммит. Без --cached файл был бы удалён и с диска.

Часть файлов вообще не должна попадать в git: пароли, кэш, логи. Как сказать об этом git заранее?

.gitignore: что git не должен видеть

В каталоге проекта лежат файлы, которых в истории быть не должно: пароли и ключи, логи, кэш Python (__pycache__), локальные данные. Если случайно закоммитить, они попадут в историю и к каждому, кто её получит. Каждый раз при git add . вручную исключать их невозможно.

Список «не брать» на таможне: перечислили типы предметов, и они не проходят в коробку.

Файл .gitignore в корне проекта содержит шаблоны (patterns) имён, по одному на строку. Файлы, подходящие под шаблон, git считает неотслеживаемыми и не предлагает в git add . и в git status. Основной синтаксис:

Строка Что значит
.env файл или каталог с таким именем в любом месте проекта
*.log любой файл, имя которого кончается на .log (* заменяет любые символы)
data/ каталог data целиком (слэш на конце значит «только каталог»)
/build только build в корне проекта, не sub/build
!keep.log исключение: этот файл всё же не игнорировать
# текст комментарий

Важные ограничения:

  • .gitignore не действует на файлы, которые уже отслеживаются. Если файл попал в индекс или в коммит, git продолжает следить за ним, даже когда шаблон подходит. Лечится git rm --cached файл.
  • Секрет в истории остаётся, даже если файл потом удалить или добавить в .gitignore: старые коммиты хранят его. Поэтому .gitignore создают до первого git add, а секрет, попавший в коммит, считают скомпрометированным (об этом в «Сломай и почини»).
  • Git не хранит пустые каталоги: он хранит файлы. Чтобы каталог попал в репозиторий, в нём должен лежать хоть один файл (принято .gitkeep).

Реальная ловушка. Мы закоммитили .env, потом добавили его в .gitignore и изменили:

$ echo 'DB_PASSWORD=s3cret' > .env
$ git add .env && git commit -m "chore: добавить .env"
$ echo '.env' > .gitignore
$ echo 'DB_PASSWORD=new' > .env
$ git status --short
 M .env
?? .gitignore
$ git check-ignore -v .env; echo "код выхода: $?"
код выхода: 1

Несмотря на .gitignore, файл показан как изменённый ( M): он уже отслеживается. А git check-ignore -v .env («проверь, игнорируется ли, и покажи правило»), ничего не напечатал и вернул код 1: для отслеживаемых файлов правила не работают. Лечение:

$ git rm --cached .env
rm '.env'
$ git check-ignore -v .env
.gitignore:1:.env	.env

Теперь git перестал следить за файлом, и правило сработало: .gitignore:1:.env значит «файл .gitignore, строка 1, шаблон .env». Но старый коммит с паролем остался: git show HEAD~1:.env всё ещё печатает DB_PASSWORD=s3cret.

Прикинь сам: в .gitignore строка *.log. Попадут ли в git status файлы logs/app.log и app.log.1?

logs/app.log не попадёт: шаблон без слэша действует в любом каталоге. app.log.1 попадёт, потому что его имя не кончается на .log.

Тут часто путают так. Что .gitignore «удаляет» уже закоммиченное. Нет, он лишь мешает файлам попасть в индекс в будущем.

Главное: .gitignore скрывает от git новые файлы по шаблонам, но не действует на уже отслеживаемые и не стирает секрет из истории.

Проверь понимание: если в ~/notes есть файл .env с секретом, а .gitignore создан после git add ., попадёт ли .env в репозиторий?

Ответ

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

Теперь запомним ещё один навык: как показать git на нужный коммит, не печатая 40 символов.

Как назвать коммит: хеш, HEAD~N, имя ветки

Почти каждая команда git принимает «какой-то коммит»: diff, show, reset, revert, switch. Значит, нужен удобный способ на него показать. Вводить каждый раз 40 цифр невозможно, и помнить их никто не будет.

Адрес дома. Можно назвать точный почтовый адрес (хеш), а можно сказать «третий дом от угла» (HEAD~3) или «дом с вывеской «Пекарня»» (имя ветки). Разные способы называют один и тот же дом, и выбираешь тот, что удобнее в данный момент. Аналогия ломается на «вывеске»: она переезжает. Имя ветки после каждого нового коммита указывает на другой дом.

Git понимает несколько форм записи:

Запись Что значит
b2049a7ef4033c629c5d0ba391dbbb31f974a49f полный хеш, всегда однозначен
b2049a7 начало хеша: подходит, пока не найдётся два коммита с таким началом (в маленьком проекте хватает 7 символов)
main, feature/slow имя ветки: коммит, на который она сейчас указывает
HEAD коммит, на котором ты стоишь
HEAD~1 или HEAD~ родитель HEAD (шаг назад по первой линии)
HEAD~3 три шага назад: родитель родителя родителя
HEAD^2 второй родитель merge commit (то есть коммит из влитой ветки)
HEAD@{1} где HEAD был на предыдущем шаге по журналу reflog (не по истории коммитов)
main..feature коммиты, которые есть в feature, но ещё нет в main

Запись HEAD~1 и HEAD^ для обычного коммита с одним родителем значат одно и то же. Различие проявляется у merge commit: у него два родителя, HEAD^1 (или HEAD~1) это тот, на котором стояла ветка при слиянии, а HEAD^2 это последний коммит влитой ветки. HEAD~N всегда идёт по первым родителям.

История из задания 2 после слияния (сверху самый новый):

ff61334 Merge branch 'feature/error'     <- HEAD, HEAD~0
ee79ebc docs: добавить README            <- HEAD^1 = HEAD~1  (main до слияния)
f5de343 feat: описать /error             <- HEAD^2           (feature/error)
20e28d4 docs: указать предел /slow       <- HEAD~2 (общий предок)

Здесь HEAD~1 это ee79ebc, а HEAD^2 это f5de343. Команда git diff HEAD~1 HEAD покажет, что слияние принесло в main: правка из feature/error. Запись main..feature/error до слияния показала бы только f5de343: единственный коммит, которого нет в main. Так проверяют, что уйдёт при слиянии.

Прикинь сам: у merge commit ff61334 родители ee79ebc (линия main) и f5de343 (линия feature). Чему равны HEAD~1 и HEAD^2?

HEAD~1 это ee79ebc, первый родитель, то есть то, где стояла main. HEAD^2 это f5de343, второй родитель, последний коммит влитой ветки.

Осторожно: HEAD~1 и HEAD@{1}. Первое: родитель по истории коммитов, у всех одинаковый. Второе: предыдущее положение HEAD в твоём локальном журнале, у другого человека там будет другое. После reset или переключения ветки эти два способа указывают на разные коммиты.

Главное: коммит можно назвать хешем, именем ветки или относительно HEAD: HEAD~N идёт по первым родителям, HEAD^2 берёт второго родителя слияния.

Проверь понимание: ты стоишь на merge commit. Как посмотреть, что именно принесла влитая ветка, а как посмотреть, что изменилось в main за это же время?

Ответ

Влитую ветку показывает сравнение первого родителя с самим слиянием: git diff HEAD^1 HEAD (это всё, что влилось в main). Для «что делала сама ветка» сравнивают второго родителя с общим предком, например git log HEAD^1..HEAD^2 покажет коммиты ветки, которых не было в main.

Мы умеем называть коммиты. Теперь посмотрим, что происходит с файлами, когда ты переходишь с ветки на ветку.

Что происходит с файлами при переключении веток

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

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

При git switch ветка git выполняет такие шаги по порядку:

  1. Сравнивает снимок текущего коммита со снимком целевой ветки: какие файлы различаются.
  2. Проверяет, нет ли у тебя незакоммиченных правок в тех файлах, которые предстоит перезаписать. Если есть, останавливается с ошибкой, и ничего не меняется.
  3. Переписывает в рабочем каталоге и индексе только различающиеся файлы: одни появляются, другие исчезают, третьи меняют содержимое.
  4. Записывает в .git/HEAD имя новой ветки.

Незакоммиченные правки в файлах, одинаковых в обеих ветках, при переключении переезжают вместе с тобой. Это удобно, но и опасно: правка «уехала» в другую ветку, и коммит потом окажется не там.

Реальные значения из задания 2. В main нет slow.txt, в feature/slow есть. После git switch main команда ls показывает только api.txt: slow.txt исчез из каталога, но не пропал из проекта, он лежит в коммитах ветки feature/slow. Обратный git switch feature/slow вернёт его.

А вот что печатает git, когда правка мешает переключению (файл f.txt изменён в обеих ветках, а у тебя есть незакоммиченная правка):

error: Your local changes to the following files would be overwritten by checkout:
	f.txt
Please commit your changes or stash them before you switch branches.
Aborting

Читаем: git назвал файл, объяснил причину («твои локальные правки были бы перезаписаны») и предложил два выхода: закоммитить или отложить через git stash.

Прикинь сам: две ветки отличаются одним файлом из двух тысяч. Сколько файлов перепишет git switch?

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

Тут часто путают так. Что переключение веток «переключает проект». На самом деле меняются только файлы рабочего каталога. Игнорируемые файлы (.env, app.log) и неотслеживаемые файлы не трогаются, поэтому в ветке может оказаться «мусор» от другой ветки.

Главное: git switch подменяет в каталоге только различающиеся файлы и отказывается работать, если твои правки были бы затёрты.

Проверь понимание: на main есть файл a.txt, в ветке feature его нет. Что произойдёт с файлом на диске при git switch feature?

Ответ

Файл a.txt исчезнет из рабочего каталога, потому что в снимке ветки его нет. Он не потерян: остался в коммитах main, и при возврате на main появится снова. Если у тебя были незакоммиченные правки в a.txt, git откажется переключаться и сообщит об этом.

Как искать в большой истории нужный коммит, не листая её подряд?

Как найти нужное в истории

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

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

У git log и соседних команд есть фильтры, их можно комбинировать:

Команда Что находит
git log --oneline -- путь коммиты, которые трогали файл или каталог
git log --grep=слово коммиты, в сообщении которых есть слово
git log -p путь те же коммиты вместе с правкой (diff) в каждом
git log --author=имя коммиты одного автора
git log --since=2.weeks коммиты за последние две недели
git show хеш сообщение и правку одного коммита
git blame путь построчно: кто и в каком коммите последним менял каждую строку

Фильтры складываются: git log --oneline --author=Ivan -- app.py покажет только коммиты Ивана, которые затронули app.py. Один и тот же приём работает для расследований: сначала git log -- путь, чтобы найти коммиты, потом git show хеш, чтобы увидеть правку, и по сообщению понять зачем.

В задании 1 команда git log --oneline -- api.txt показала оба коммита, потому что оба меняли api.txt. А git log --stat --oneline для каждого коммита добавляет таблицу «файл сколько строк изменено»: api.txt | 1 + значит «одна строка добавлена». Знак + это добавления, - удаления, число слева от них это общее количество изменённых строк в файле. Так за секунды видно, какой коммит большой, а какой мелкий.

Прикинь сам: нужны коммиты автора Ivan за последние две недели, которые трогали app.py. Как собрать команду?

git log --oneline --since=2.weeks --author=Ivan -- app.py: фильтры складываются, а путь после -- ограничивает историю одним файлом.

Осторожно: «git blame показывает виноватого». Он показывает последнего, кто трогал строку, а это может быть форматирование или переименование. Читай не только имя, но и сообщение коммита, и при необходимости смотри git log -p дальше в прошлое. Название команды не повод искать виноватого: цель понять причину изменения.

Главное: git log с фильтрами по пути, автору, дате и слову в сообщении, плюс git show и git blame, отвечают на «кто, когда и зачем».

Проверь понимание: ты видишь странную строку в app.py и хочешь понять, зачем её добавили. Какие две команды пригодятся?

Ответ

git blame app.py покажет, в каком коммите строка появилась последней. Затем git show <хеш> покажет весь коммит: правку и сообщение с объяснением, зачем. Если сообщение бесполезное («правки»), это лишний аргумент за нормальные сообщения.

Мы научились читать историю. Остался вопрос: что с ней можно делать, а что нельзя?

Что можно переписывать, а что нельзя

Многие команды из этого урока (reset, commit --amend, filter-repo, rebase, который придёт в следующем уроке) меняют историю. Пока история только твоя, это безопасно. Но как только её увидели другие, переписывание ломает им работу. Правило нужно знать заранее, а не выучить на инциденте.

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

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

  • локальные коммиты, которые никто не получал: переписывать можно (reset, commit --amend);
  • опубликованные коммиты в общих ветках (main): не переписывать, отменять через revert;
  • исключение: секрет в истории. Но и тут порядок такой: сначала ротация секрета (замена пароля или ключа на новый: старый считаем украденным), потом согласованная чистка и переклонирование у всех.

Сценарий 2 из «Сломай и почини»: git filter-repo вырезает файл из всех коммитов, и хеши коммитов после него меняются (в реальном прогоне docs: добавить README получил новый хеш 70a6583, а не старый). Пока репозиторий локальный, это безопасно. Если бы им уже пользовались коллеги, им пришлось бы заново клонировать проект.

Прикинь сам: в ветке два последних коммита: A только у тебя, B уже в общей ветке. Какой из них можно поправить через commit --amend?

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

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

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

Проверь понимание: ты хочешь поправить сообщение коммита, который позавчера отправил в общую ветку main. Можно ли переписать его через commit --amend?

Ответ

Нет. Коммит уже видели другие, переписывание создаст новый хеш и расхождение с их копиями. Оставь сообщение как есть и в следующем коммите поясни, что нужно. Переписывать общую историю допустимо только в особых случаях (утёкший секрет) и с согласованием всей команды.

Последний штрих перед практикой: настроить git так, чтобы история получалась читаемой.

Настройки git и сообщения коммитов

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

Подпись и тема письма: без подписи письмо анонимно, без темы его никто не найдёт в почте.

Настройки git хранятся в файлах трёх уровней, и более близкий уровень перекрывает дальний:

Уровень Флаг Файл Действует на
system --system /etc/gitconfig всех пользователей машины
global --global ~/.gitconfig все репозитории пользователя
local (без флага) .git/config один репозиторий

Обязательные настройки: user.name и user.email (они попадают в каждый коммит). Почта может быть любой, реальную указывать необязательно. init.defaultBranch задаёт имя первой ветки, core.editor выбирает редактор для сообщений коммитов, когда ты пишешь git commit без -m.

Сообщение коммита. Заголовок отвечает на вопрос «что изменилось», а тело (если нужно, через пустую строку) объясняет «почему». В командах принято соглашение Conventional Commits (условно: «договорённость о коммитах»): в начале тип, потом двоеточие, потом суть в повелительном наклонении:

Тип Когда
feat новая возможность (feat: добавить /headers)
fix исправление ошибки (fix: вернуть 405 на DELETE)
docs только документация (docs: описать запуск)
chore служебное, не влияющее на работу (chore: добавить .gitignore)
refactor, test, ci переработка кода без смены поведения, тесты, конфигурация CI

Заголовок держат до 72 символов. Полезная сторона такой договорённости: по префиксам можно искать (git log --oneline | grep feat) и потом автоматически собирать журнал изменений релиза (урок 3.5).

Твой файл настроек после четырёх команд из практики выглядит так (реальный вывод cat ~/.gitconfig):

[user]
	name = Ivan Petrov
	email = ivan@example.com
[init]
	defaultBranch = main
[core]
	editor = nano

Секции в квадратных скобках это группы: git config --global user.name "..." записывает в секцию [user] ключ name. Если забыть настройку, git не даст сделать коммит и напечатает Author identity unknown (полный текст в практике).

Прикинь сам: в одной ветке два коммита: «правки» и fix: вернуть 405 на DELETE. По какому из них найдёт git log --oneline | grep fix?

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

Главное: git подписывает коммиты автором из настроек, а договорённость Conventional Commits (тип: суть) делает историю читаемой и пригодной для поиска.

Проверь понимание: чем плохо сообщение «правки» и как переписать его для коммита, который добавляет строку GET /slow в список ручек?

Ответ

Оно не говорит ни что изменилось, ни где. Лучше: docs: добавить /slow. Тип docs, потому что правится документация, а суть видна из заголовка.

Теперь теории хватит. Переходим к практике: пройдём весь путь на настоящем репозитории.

Практика

Все задания идут на твоей ВМ с Ubuntu из урока 1.1. Git там уже установлен (если нет, поставь: sudo apt install -y git). Для заданий 1-4 нужен отдельный каталог ~/git-lab, чтобы не задеть проект. Задание 5 работает в ~/notes. Вывод ниже получен в Ubuntu 24.04 с git 2.43.0. Твои хеши коммитов и время будут другими, а хеши файлов и деревьев (8a7761e, 3aa64fc, 021fc7a) совпадут.

Перед началом задай имя и настрой git (один раз на машине). Разбор: git --version печатает версию, git config --global ключ значение записывает настройку в ~/.gitconfig, nano это простой редактор из урока 1.8:

# версия и базовая настройка; почту можно взять любую, реальную указывать необязательно
git --version
git config --global user.name "Ivan Petrov"
git config --global user.email "ivan@example.com"
git config --global init.defaultBranch main
git config --global core.editor nano
cat ~/.gitconfig

Вместо Ivan Petrov и ivan@example.com впиши своё. Ожидаемый вывод (первая строка и содержимое файла):

git version 2.43.0
[user]
	name = Ivan Petrov
	email = ivan@example.com
[init]
	defaultBranch = main
[core]
	editor = nano

Три git config молча ничего не печатают, это нормально: молчание в Linux значит успех (урок 1.2).

Задание 1. Репозиторий и путь файла через три области

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

Предскажи: после git init и создания файла что покажет git status: файл в «untracked» или в «changes to be committed»? А после git add?

Ответ

Сначала untracked (git о нём не знает), после add он в «Changes to be committed» (в индексе).

Шаги:

  1. Создай репозиторий и файл. Разбор: mkdir -p создаёт каталог (без ошибки, если он уже есть), && выполняет вторую команду, только если первая удалась, git init создаёт .git, printf '...\n' > api.txt записывает две строки в файл (\n это перевод строки), git status показывает состояние:

    mkdir -p ~/git-lab && cd ~/git-lab
    git init
    git status
    printf 'GET /healthz\nGET /headers\n' > api.txt
    git status
    

    Что должно получиться:

    Initialized empty Git repository in /home/ubuntu/git-lab/.git/
    On branch main
    
    No commits yet
    
    nothing to commit (create/copy files and use "git add" to track)
    On branch main
    
    No commits yet
    
    Untracked files:
      (use "git add <file>..." to include in what will be committed)
    	api.txt
    
    nothing added to commit but untracked files present (use "git add" to track)
    

    Как читать вывод: On branch main: ты на ветке main. No commits yet: истории ещё нет. В «Untracked files» git перечисляет файлы, о которых он не знает, и подсказкой в скобках говорит, что делать. Пути в первой строке у тебя свои (/home/<твоё имя>/git-lab/.git/). Читать подсказки git в скобках полезно: в них ровно нужная команда.

  2. Добавь в индекс и закоммить. git status --short это короткая форма (две колонки, см. теорию). Ключ -m задаёт сообщение сразу, без редактора:

    git add api.txt
    git status --short
    git commit -m "docs: перечислить ручки API"
    
    A  api.txt
    [main (root-commit) 479bef3] docs: перечислить ручки API
     1 file changed, 2 insertions(+)
     create mode 100644 api.txt
    

    Как читать вывод: A api.txt файл в индексе. В квадратных скобках: ветка main, (root-commit) значит «первый коммит без родителя», хеш 479bef3 (у тебя другой), сообщение. Ниже итог: изменён один файл, добавлено две строки, а create mode 100644 сообщает, что создан обычный файл с правами rw-r--r--.

  3. Измени файл и сравни области. Команда >> дописывает строку в конец:

    echo 'GET /slow' >> api.txt
    git status --short
    git diff            # рабочий каталог против индекса
    git add api.txt
    git status --short
    git diff            # теперь пусто
    git diff --staged   # индекс против последнего коммита
    git commit -m "docs: добавить /slow"
    
     M api.txt
    diff --git a/api.txt b/api.txt
    index 8a7761e..3aa64fc 100644
    --- a/api.txt
    +++ b/api.txt
    @@ -1,2 +1,3 @@
     GET /healthz
     GET /headers
    +GET /slow
    M  api.txt
    diff --git a/api.txt b/api.txt
    index 8a7761e..3aa64fc 100644
    --- a/api.txt
    +++ b/api.txt
    @@ -1,2 +1,3 @@
     GET /healthz
     GET /headers
    +GET /slow
    [main b2049a7] docs: добавить /slow
     1 file changed, 1 insertion(+)
    

    Как читать вывод: ` M (пробел слева) значит «изменён, но не в индексе»; после add строка становится M ` (буква слева). Первый git diff показал правку. После add второй git diff не напечатал ничего (в блоке выше между двумя дифами это пусто), а git diff --staged показал ту же правку: она переехала из «рабочий против индекса» в «индекс против коммита».

  4. Загляни внутрь коммита и в .git. cat-file -p печатает объект, -t показывает его тип, HEAD^{tree} значит «дерево последнего коммита». Кавычки нужны, чтобы оболочка не трогала скобки:

    git log
    git log --oneline
    git cat-file -p HEAD
    git cat-file -p 'HEAD^{tree}'
    ls -A .git
    cat .git/HEAD
    cat .git/refs/heads/main
    find .git/objects -type f | sort
    
    commit b2049a7ef4033c629c5d0ba391dbbb31f974a49f
    Author: Ivan Petrov <ivan@example.com>
    Date:   Wed Sep 30 12:12:37 2026 +0000
    
        docs: добавить /slow
    
    commit 479bef3f4f8fde6fc58a35718d51d7400c2f7735
    Author: Ivan Petrov <ivan@example.com>
    Date:   Wed Sep 30 12:12:37 2026 +0000
    
        docs: перечислить ручки API
    b2049a7 docs: добавить /slow
    479bef3 docs: перечислить ручки API
    tree 021fc7a011e0bc0e4ec625bd9e72987edd512dbc
    parent 479bef3f4f8fde6fc58a35718d51d7400c2f7735
    author Ivan Petrov <ivan@example.com> 1790770357 +0000
    committer Ivan Petrov <ivan@example.com> 1790770357 +0000
    
    docs: добавить /slow
    100644 blob 3aa64fc35f17ab606070b5c21562309c4486c885	api.txt
    COMMIT_EDITMSG
    HEAD
    branches
    config
    description
    hooks
    index
    info
    logs
    objects
    refs
    ref: refs/heads/main
    b2049a7ef4033c629c5d0ba391dbbb31f974a49f
    .git/objects/02/1fc7a011e0bc0e4ec625bd9e72987edd512dbc
    .git/objects/3a/a64fc35f17ab606070b5c21562309c4486c885
    .git/objects/3c/0961a8105355835d71d807ed4315fe574723b4
    .git/objects/47/9bef3f4f8fde6fc58a35718d51d7400c2f7735
    .git/objects/8a/7761e87cb29cf054a04b83ecf9cb9dabdb1911
    .git/objects/b2/049a7ef4033c629c5d0ba391dbbb31f974a49f
    

    Как читать вывод: в git log полный 40-значный хеш, автор и сообщение, новые коммиты сверху. cat-file -p HEAD показывает поля коммита из теории: tree, parent, авторов и сообщение. Файл .git/HEAD содержит ref: refs/heads/main («я на ветке main»), а файл ветки .git/refs/heads/main содержит хеш последнего коммита (b2049a7..., полный). В objects шесть файлов: два коммита, два дерева и два состояния api.txt (blob). Имя файла это хеш: первые две цифры дают имя подкаталога, остальные 38 имя файла.

  5. Посмотри git log в других формах. --stat добавляет число изменённых строк по файлам, --format=... задаёт свой шаблон (%h короткий хеш, %an автор, %ad дата, %s заголовок), --date=short короткая дата, -- с именем файла ограничивает историю этим файлом:

    git log --stat --oneline
    git log --format='%h %an %ad %s' --date=short
    git log --oneline -- api.txt
    
    b2049a7 docs: добавить /slow
     api.txt | 1 +
     1 file changed, 1 insertion(+)
    479bef3 docs: перечислить ручки API
     api.txt | 2 ++
     1 file changed, 2 insertions(+)
    b2049a7 Ivan Petrov 2026-09-30 docs: добавить /slow
    479bef3 Ivan Petrov 2026-09-30 docs: перечислить ручки API
    b2049a7 docs: добавить /slow
    479bef3 docs: перечислить ручки API
    

Объясни себе:

  • Почему после второго git add команда git diff без флагов пустая?
  • Чем git diff --staged отличается от git diff?
  • Почему в .git/objects шесть файлов, а не два?

Типичные ошибки:

  • Author identity unknown и *** Please tell me who you are. (дальше fatal: unable to auto-detect email address): не заданы user.name и user.email. Выполни команды git config --global из начала практики и повтори коммит.
  • fatal: not a git repository (or any of the parent directories): .git: ты вне репозитория или забыл git init. Проверь pwd, перейди в ~/git-lab.
  • Открылся редактор, а ты не хотел: команда git commit без -m просит сообщение. В nano напиши заголовок, сохрани (Ctrl+O, Enter) и выйди (Ctrl+X). Пустое сообщение отменяет коммит.
  • hint: Using 'master' as the name for the initial branch. при git init: не задан init.defaultBranch. Настрой его или переименуй ветку: git branch -m main.

Задание 2. Осмысленная история, ветка и merge

Цель: сделать серию понятных коммитов, поработать в ветке и слить её двумя способами, потом побывать в отсоединённом HEAD и вернуться.

Предскажи: сколько родителей будет у коммита после git merge, если main за время работы ветки не менялся? А если менялся?

Ответ

Если main не менялся: слияние fast-forward, нового коммита нет вообще. Если менялся: merge commit с двумя родителями.

Шаги:

  1. Ветка feature/slow и два коммита в ней. git branch без аргументов показывает список веток (звёздочка у текущей), git commit -am это add для уже отслеживаемых файлов и commit вместе (новые файлы так не добавляются), --all в git log показывает все ветки, --graph рисует линии:

    cd ~/git-lab
    git branch
    git switch -c feature/slow
    cat .git/HEAD
    cat .git/refs/heads/feature/slow
    printf 'slow: ждёт N секунд\n' > slow.txt
    git add slow.txt && git commit -m "feat: описать /slow"
    echo 'предел: 120 секунд' >> slow.txt
    git commit -am "docs: указать предел /slow"
    git log --oneline --graph --all
    
    * main
    Switched to a new branch 'feature/slow'
    ref: refs/heads/feature/slow
    b2049a7ef4033c629c5d0ba391dbbb31f974a49f
    [feature/slow 128a8a9] feat: описать /slow
     1 file changed, 1 insertion(+)
     create mode 100644 slow.txt
    [feature/slow 20e28d4] docs: указать предел /slow
     1 file changed, 1 insertion(+)
    * 20e28d4 docs: указать предел /slow
    * 128a8a9 feat: описать /slow
    * b2049a7 docs: добавить /slow
    * 479bef3 docs: перечислить ручки API
    

    Как читать вывод: новая ветка появилась мгновенно: файл refs/heads/feature/slow содержит тот же хеш b2049a7..., что был у main. Два новых коммита выросли над ним. Граф пока прямой, потому что main осталась на b2049a7.

  2. Вернись в main и слей. Заметь, что slow.txt в main не было (ls его не покажет), а после слияния появится:

    git switch main
    ls
    git merge feature/slow
    git log --oneline --graph
    
    Switched to branch 'main'
    api.txt
    Updating b2049a7..20e28d4
    Fast-forward
     slow.txt | 2 ++
     1 file changed, 2 insertions(+)
     create mode 100644 slow.txt
    * 20e28d4 docs: указать предел /slow
    * 128a8a9 feat: описать /slow
    * b2049a7 docs: добавить /slow
    * 479bef3 docs: перечислить ручки API
    

    Как читать вывод: после switch файл slow.txt пропал из каталога (ls показывает только api.txt): git подменил рабочие файлы на состояние ветки. Fast-forward значит перемотку: указатель main переехал с b2049a7 на 20e28d4, новых коммитов нет, история осталась прямой линией.

  3. Теперь настоящее расхождение: обе ветки получают по коммиту. Ключ --no-edit не открывает редактор для сообщения слияния:

    git switch -c feature/error
    echo 'GET /error: всегда 500' > error.txt
    git add error.txt && git commit -m "feat: описать /error"
    git switch main
    echo '# API заметок' > README.md
    git add README.md && git commit -m "docs: добавить README"
    git log --oneline --graph --all
    git merge --no-edit feature/error
    git log --oneline --graph
    git cat-file -p HEAD
    
    Switched to a new branch 'feature/error'
    [feature/error f5de343] feat: описать /error
     1 file changed, 1 insertion(+)
     create mode 100644 error.txt
    Switched to branch 'main'
    [main ee79ebc] docs: добавить README
     1 file changed, 1 insertion(+)
     create mode 100644 README.md
    * f5de343 feat: описать /error
    | * ee79ebc docs: добавить README
    |/  
    * 20e28d4 docs: указать предел /slow
    * 128a8a9 feat: описать /slow
    * b2049a7 docs: добавить /slow
    * 479bef3 docs: перечислить ручки API
    Merge made by the 'ort' strategy.
     error.txt | 1 +
     1 file changed, 1 insertion(+)
     create mode 100644 error.txt
    *   ff61334 Merge branch 'feature/error'
    |\  
    | * f5de343 feat: описать /error
    * | ee79ebc docs: добавить README
    |/  
    * 20e28d4 docs: указать предел /slow
    * 128a8a9 feat: описать /slow
    * b2049a7 docs: добавить /slow
    * 479bef3 docs: перечислить ручки API
    tree bb0d87529bbd0add028f7cb48154e765132736eb
    parent ee79ebcd453ffb865d2d5ae536295cdfe1d3aaab
    parent f5de343d60a97b54a1c28fec981cf7c438dfe9a5
    author Ivan Petrov <ivan@example.com> 1790770357 +0000
    committer Ivan Petrov <ivan@example.com> 1790770357 +0000
    
    Merge branch 'feature/error'
    

    Как читать вывод: первый граф показывает развилку: от 20e28d4 расходятся две линии (f5de343 в ветке feature/error, ee79ebc в main). Merge made by the 'ort' strategy значит, что git сам объединил изменения (ort это название алгоритма слияния, в старых версиях recursive). Второй граф это ромб: ff61334 слил две линии. У этого коммита в cat-file две строки parent.

  4. Удали слитые ветки, потом попробуй удалить неслитую (git branch -d откажет) и принудительно:

    git branch -d feature/slow feature/error
    git branch
    git switch -c experiment
    echo 'GET /leak' >> api.txt
    git commit -am "feat: черновик /leak"
    git switch main
    git branch -d experiment
    git branch -D experiment
    
    Deleted branch feature/slow (was 20e28d4).
    Deleted branch feature/error (was f5de343).
    * main
    Switched to a new branch 'experiment'
    [experiment a42bf43] feat: черновик /leak
     1 file changed, 1 insertion(+)
    Switched to branch 'main'
    error: the branch 'experiment' is not fully merged.
    If you are sure you want to delete it, run 'git branch -D experiment'
    Deleted branch experiment (was a42bf43).
    

    В Ubuntu 26.04 (git 2.53) к отказу добавлены строки-подсказки hint:, а сама команда та же. Как читать вывод: Deleted branch ... (was 20e28d4) печатает хеш, на который указывала ветка: запомнить его удобно, если удалил зря. Ветка experiment не слита в main, поэтому -d отказал, а -D удалил её (её коммит a42bf43 теперь недостижим, но живёт в reflog).

  5. Отсоединённый HEAD. Переключись на коммит (не на ветку), сделай в нём коммит и уйди, потом спаси работу.

    git switch --detach HEAD~1
    git status
    echo 'GET /detached' > detached.txt
    git add detached.txt && git commit -m "feat: работа без ветки"
    git switch main
    
    HEAD is now at ee79ebc docs: добавить README
    HEAD detached at ee79ebc
    nothing to commit, working tree clean
    [detached HEAD 6a22132] feat: работа без ветки
     1 file changed, 1 insertion(+)
     create mode 100644 detached.txt
    Warning: you are leaving 1 commit behind, not connected to
    any of your branches:
    
      6a22132 feat: работа без ветки
    
    If you want to keep it by creating a new branch, this may be a good time
    to do so with:
    
     git branch <new-branch-name> 6a22132
    
    Switched to branch 'main'
    

    Git сам предупредил, что коммит 6a22132 остался «висеть» без ветки, и подсказал команду. Спасём его: подставь хеш из своего предупреждения и проверь, что работа нашлась, потом убери учебную ветку:

    git reflog -4
    git switch -c rescue 6a22132     # твой хеш из предупреждения
    git log --oneline -2
    git switch main
    git branch -D rescue
    

    Хеш из предупреждения (6a22132 у меня, у тебя свой) подставляй в git switch -c rescue. Тот же хеш есть во второй строке git reflog (HEAD@{1}: commit: feat: работа без ветки). После спасения git log --oneline -2 для ветки rescue показывает 6a22132 feat: работа без ветки первой строкой.

Объясни себе:

  • Почему git branch -d разрешил удалить ветки сразу после слияния, а experiment нет?
  • Почему в первом слиянии нет нового коммита, а во втором есть?
  • Что бы произошло с коммитом 6a22132, если бы ты не создал ветку и не записал хеш?

Типичные ошибки:

  • error: Your local changes to the following files would be overwritten by checkout: (дальше имя файла и Please commit your changes or stash them before you switch branches. Aborting): при переключении в рабочем каталоге незакоммиченные правки того же файла. Закоммить их или отложи git stash.
  • error: the branch 'feature/x' is not fully merged.: -d защищает от потери неслитых коммитов. Сначала слей ветку, либо осознанно удали через -D.
  • fatal: a branch named 'feature/slow' already exists: ветка с таким именем уже есть. Придумай другое имя или переключись командой git switch без -c.

Задание 3. Отмена: restore, revert, reset и спасение из reflog

Цель: отработать все виды отмены, вернуть «потерянный» коммит и убедиться, что незакоммиченное вернуть нельзя.

Предскажи: после git reset --hard HEAD~1 коммит физически стёрт с диска или его ещё можно достать?

Ответ

Можно достать: объект остался в базе, пока его не удалит git gc. Найти его поможет git reflog.

Шаги:

  1. Отмена правок до коммита. restore возвращает файл из индекса, restore --staged вынимает файл из индекса, но не трогает правку в файле:

    cd ~/git-lab
    echo 'мусор' >> api.txt
    git status --short
    git restore api.txt
    git status --short
    echo 'GET /wrong' >> api.txt
    git add api.txt
    git status --short
    git restore --staged api.txt
    git status --short
    git restore api.txt
    
     M api.txt
    M  api.txt
     M api.txt
    

    Как читать вывод: первая строка ` M api.txt (правка есть, в индексе нет). После restore api.txt пусто: правка отменена. Дальше M ` (правка в индексе), после restore --staged снова ` M (правка вернулась в рабочий каталог, но не в индекс), последний restore` окончательно сбросил файл.

  2. Отмена коммита без переписывания истории. git log --oneline -4 печатает четыре последних коммита:

    echo 'GET /wrong' >> api.txt
    git commit -am "docs: случайная ручка"
    git revert --no-edit HEAD
    git log --oneline -4
    cat api.txt
    
    [main fa787b5] docs: случайная ручка
     1 file changed, 1 insertion(+)
    [main 971f3e7] Revert "docs: случайная ручка"
     Date: Wed Sep 30 12:12:38 2026 +0000
     1 file changed, 1 deletion(-)
    971f3e7 Revert "docs: случайная ручка"
    fa787b5 docs: случайная ручка
    ff61334 Merge branch 'feature/error'
    ee79ebc docs: добавить README
    GET /healthz
    GET /headers
    GET /slow
    

    Как читать вывод: в истории теперь оба коммита: ошибка и её отмена (Revert "..."). Файл вернулся к состоянию без /wrong.

  3. Три режима reset. Сделаем коммит и откатим его тремя способами. Внимание: --hard в конце уничтожит незакоммиченную правку:

    echo 'GET /important' >> api.txt
    git commit -am "docs: важная ручка"
    git reset --soft HEAD~1
    git status --short
    git log --oneline -2
    git reset --mixed HEAD
    git status --short
    git reset --hard HEAD
    git status --short
    cat api.txt
    
    [main 6c04595] docs: важная ручка
     1 file changed, 1 insertion(+)
    M  api.txt
    971f3e7 Revert "docs: случайная ручка"
    fa787b5 docs: случайная ручка
    Unstaged changes after reset:
    M	api.txt
     M api.txt
    HEAD is now at 971f3e7 Revert "docs: случайная ручка"
    GET /healthz
    GET /headers
    GET /slow
    

    Как читать вывод: после --soft коммит «важной ручки» пропал из log, но правка в индексе (M слева). После --mixed правка ушла из индекса в рабочий каталог ( M). После --hard статус пуст, а в api.txt нет строки GET /important: правка стёрта, и даже reflog её не вернёт, потому что она не была в коммите. Хеш 6c04595 у тебя будет другим.

  4. Теперь спасение закоммиченной работы. Коммитим заново, теряем через reset --hard и возвращаем по reflog:

    echo 'GET /important' >> api.txt
    git commit -am "docs: важная ручка"
    git reset --hard HEAD~1
    git log --oneline -3
    cat api.txt
    git reflog -4
    
    [main 6c04595] docs: важная ручка
     1 file changed, 1 insertion(+)
    HEAD is now at 971f3e7 Revert "docs: случайная ручка"
    971f3e7 Revert "docs: случайная ручка"
    fa787b5 docs: случайная ручка
    ff61334 Merge branch 'feature/error'
    GET /healthz
    GET /headers
    GET /slow
    971f3e7 HEAD@{0}: reset: moving to HEAD~1
    6c04595 HEAD@{1}: commit: docs: важная ручка
    971f3e7 HEAD@{2}: reset: moving to HEAD
    971f3e7 HEAD@{3}: reset: moving to HEAD
    

    Верни коммит. Подставь номер строки из своего reflog, у меня это HEAD@{1} (строка commit: docs: важная ручка):

    git reset --hard 'HEAD@{1}'
    git log --oneline -3
    cat api.txt
    
    HEAD is now at 6c04595 docs: важная ручка
    6c04595 docs: важная ручка
    971f3e7 Revert "docs: случайная ручка"
    fa787b5 docs: случайная ручка
    GET /healthz
    GET /headers
    GET /slow
    GET /important
    

    Как читать вывод: после reset --hard HEAD~1 коммита «важная ручка» в log нет, но в reflog он есть под номером HEAD@{1}. После возврата он снова на месте, и строка GET /important снова в api.txt, потому что она была закоммичена.

  5. stash: отложить правки и вернуть. Ключевые команды: git stash откладывает, git stash list показывает список, git stash pop возвращает и удаляет из списка:

    echo 'GET /draft' >> api.txt
    git stash
    git status --short
    git stash list
    git stash pop
    git status --short
    git restore api.txt
    
    Saved working directory and index state WIP on main: 6c04595 docs: важная ручка
    stash@{0}: WIP on main: 6c04595 docs: важная ручка
    On branch main
    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:   api.txt
    
    no changes added to commit (use "git add" and/or "git commit -a")
    Dropped refs/stash@{0} (b43248072f5e0518be537e9dbce96f60897a49f0)
     M api.txt
    

    Как читать вывод: после stash git status --short пустой (чистый каталог), в списке одна запись. После pop правка вернулась ( M api.txt), запись из списка исчезла (Dropped).

Объясни себе:

  • Чем revert принципиально отличается от reset --hard для истории?
  • Почему в шаге 3 правка GET /important пропала безвозвратно, а в шаге 4 вернулась?
  • Что в шаге 4 случилось бы с незакоммиченными правками в момент reset --hard 'HEAD@{1}'?

Типичные ошибки:

  • error: could not revert 2a62176... second с CONFLICT (content): Merge conflict in f.txt: отмена перекрывается с более поздними правками. Разреши конфликт вручную (см. урок 3.2), затем git add файл и git revert --continue, либо откажись: git revert --abort.
  • fatal: log for 'HEAD' only has 7 entries при git reset --hard 'HEAD@{50}': такой записи в reflog нет. Посмотри git reflog и выбери существующий номер.
  • В zsh (оболочка по умолчанию на Mac) фигурные скобки бывают особыми символами. Кавычки 'HEAD@{1}' работают везде.

Перед тем как выполнить совет нейросети про reset, revert или clean, спроси её, что будет с незакоммиченными правками и с историей, и проверь по таблице из раздела «Отмена». Начни с копии каталога или с git stash, если сомневаешься.

Задание 4. Поиск сломанного коммита через bisect

Цель: найти коммит, который сломал код, за логарифмическое число шагов.

Предскажи: сколько проверок понадобится bisect, чтобы найти виновного среди 16 коммитов?

Ответ

Не больше четырёх (log2 от 16): каждый шаг делит диапазон пополам.

Шаги:

  1. Сгенерируй историю из 12 коммитов, где начиная с 8-го файл state.txt содержит broken. Разбор скрипта: seq 1 12 печатает числа от 1 до 12, for i in ...; do ... done повторяет тело цикла для каждого, [ "$i" -ge 8 ] это проверка «i больше или равно 8», git commit -q коммит без лишнего вывода, wc -l считает строки:

    mkdir -p ~/git-bisect && cd ~/git-bisect && git init -q
    for i in $(seq 1 12); do
      if [ "$i" -ge 8 ]; then echo "broken" > state.txt; else echo "ok" > state.txt; fi
      echo "изменение $i" >> log.txt
      git add . && git commit -q -m "chore: коммит $i"
    done
    git log --oneline | wc -l
    git log --oneline
    
    12
    e215904 chore: коммит 12
    d78ed14 chore: коммит 11
    ec94c9c chore: коммит 10
    c2abd30 chore: коммит 9
    1630689 chore: коммит 8
    91db1e6 chore: коммит 7
    c89e0a4 chore: коммит 6
    3c0a4d7 chore: коммит 5
    99d3e64 chore: коммит 4
    9232925 chore: коммит 3
    c5dfe95 chore: коммит 2
    25a7390 chore: коммит 1
    
  2. Напиши проверку. Она возвращает код 0, если state.txt содержит ok (хорошо), и 1 иначе. Команда [ ... ] сравнивает строки и выставляет код возврата. chmod +x даёт файлу право запуска (урок 1.3):

    printf '#!/bin/sh\n[ "$(cat state.txt)" = "ok" ]\n' > check.sh
    chmod +x check.sh
    git status --short
    
    ?? check.sh
    

    Файл check.sh не закоммичен (??), поэтому при переключении между коммитами он остаётся на месте и не мешает.

  3. Запусти автоматический bisect. git bisect start начинает поиск, bad HEAD отмечает текущий коммит как сломанный, good HEAD~11 отмечает первый коммит (11 шагов назад) как рабочий, run ./check.sh сам проверяет коммиты, пока не найдёт виновного, reset возвращает всё как было:

    git bisect start
    git bisect bad HEAD
    git bisect good HEAD~11
    git bisect run ./check.sh
    git bisect reset
    
    status: waiting for both good and bad commits
    status: waiting for good commit(s), bad commit known
    Bisecting: 5 revisions left to test after this (roughly 3 steps)
    [c89e0a45700ed49e4e2584c604dbb6644742ecf5] chore: коммит 6
    running './check.sh'
    Bisecting: 2 revisions left to test after this (roughly 2 steps)
    [c2abd3073bfeb9c372abad375958258ed0d16565] chore: коммит 9
    running './check.sh'
    Bisecting: 0 revisions left to test after this (roughly 1 step)
    [16306899f68e5b089d496f2fd055bab8f69ce46f] chore: коммит 8
    running './check.sh'
    Bisecting: 0 revisions left to test after this (roughly 0 steps)
    [91db1e6fc7fae70eb46b6979dc7d971a0db865ef] chore: коммит 7
    running './check.sh'
    16306899f68e5b089d496f2fd055bab8f69ce46f is the first bad commit
    commit 16306899f68e5b089d496f2fd055bab8f69ce46f
    Author: Ivan Petrov <ivan@example.com>
    Date:   Wed Sep 30 12:12:38 2026 +0000
    
        chore: коммит 8
    
     log.txt   | 1 +
     state.txt | 2 +-
     2 files changed, 2 insertions(+), 1 deletion(-)
    bisect found first bad commit
    Previous HEAD position was 91db1e6 chore: коммит 7
    Switched to branch 'main'
    

    Вывод у тебя будет тем же по смыслу, а хеши и время другими.

    Как читать вывод: после good git сам переключился на середину диапазона (коммит 6) и сказал, что осталось около трёх шагов. Дальше bisect run четыре раза запускает check.sh (строки running): на коммитах 6 (хорошо), 9 (плохо), 8 (плохо), 7 (хорошо). Из этого следует, что первый плохой коммит это 8. Из 12 коммитов понадобилось четыре проверки, а не двенадцать. bisect reset вернул тебя на main.

Объясни себе:

  • Почему скрипту проверки нужен код возврата, а не текст?
  • Сколько проверок понадобилось бы на 1000 коммитов?

Типичные ошибки:

  • You need to start by "git bisect start": команды good и bad до start. Начни с git bisect start.
  • error: unable to verify './check.sh' on good revision и Permission denied или not found перед ним: скрипт не запускается на проверочном коммите (нет права запуска или неверный путь). Выполни chmod +x check.sh и проверь путь. Затем git bisect reset и начни заново.
  • В bisect сообщения не идут в git log, а статус показывает You are currently bisecting: ты забыл git bisect reset, и HEAD стоит на пробном коммите.

Задание 5. Шаг проекта: «Заметки» становятся репозиторием

Цель: превратить ~/notes в git-репозиторий, не закоммитив мусор и секреты.

Предскажи: если в ~/notes есть файл .env с секретом, а .gitignore создан после git add ., попадёт ли .env в репозиторий?

Ответ

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

Шаги:

  1. Сначала ловушка на учебном репозитории (безопасно: пароль выдуманный). Разбор: git check-ignore -v файл объясняет, какое правило игнорирует файл; ; echo "код выхода: $?" печатает код возврата последней команды:

    cd ~/git-lab
    echo 'DB_PASSWORD=s3cret' > .env
    git add .env && git commit -m "chore: добавить .env"
    echo '.env' > .gitignore
    echo 'DB_PASSWORD=new' > .env
    git status --short
    git check-ignore -v .env; echo "код выхода: $?"
    git rm --cached .env
    git check-ignore -v .env
    git add .gitignore && git commit -m "chore: убрать .env из git"
    git status --short
    git log --oneline --all -- .env
    git show HEAD~1:.env
    
    [main c96ee58] chore: добавить .env
     1 file changed, 1 insertion(+)
     create mode 100644 .env
     M .env
    ?? .gitignore
    код выхода: 1
    rm '.env'
    .gitignore:1:.env	.env
    [main 812947e] chore: убрать .env из git
     2 files changed, 1 insertion(+), 1 deletion(-)
     delete mode 100644 .env
     create mode 100644 .gitignore
    812947e chore: убрать .env из git
    c96ee58 chore: добавить .env
    DB_PASSWORD=s3cret
    

    Как читать вывод: .env в .gitignore, но статус показывает ` M .env: файл уже отслеживался. check-ignore промолчал (код 1: правило не применилось). После git rm –cached .env правило заработало (.gitignore:1:.env это файл, строка и шаблон). Последняя команда показывает главное: пароль остался в истории, в коммите c96ee58`. Хеши у тебя будут другими.

  2. Теперь проект. Создай в ~/notes файл .env с выдуманным секретом и app.log, чтобы проверить защиту, инициализируй репозиторий и создай .gitignore до первого add. Разбор: git init -b main создаёт репозиторий с веткой main, cat > файл <<'EOT' ... EOT записывает всё между EOT в файл (кавычки вокруг EOT запрещают оболочке подставлять переменные):

    cd ~/notes
    echo 'DB_PASSWORD=s3cret' > .env
    echo 'test' > app.log
    ls -A
    git init -b main
    cat > .gitignore <<'EOT'
    .env
    *.log
    __pycache__/
    .venv/
    data/
    EOT
    
    .env
    Makefile
    __pycache__
    app.log
    app.py
    deploy
    requirements.txt
    scripts
    test_app.py
    Initialized empty Git repository in /home/ubuntu/notes/.git/
    

    Если ты уже делал git init в этом каталоге раньше, git напишет Reinitialized existing Git repository in ...: это не ошибка, ничего не потеряно. У тебя в ls -A может не быть __pycache__ (он появляется после make test или make lint) и .env, если ты его не создавал.

    Что значат строки .gitignore: .env секреты; *.log любые логи; __pycache__/ кэш скомпилированного Python (каталог создаёт сам Python); .venv/ виртуальное окружение (появится в уроке 3.4); data/ каталог данных, если решишь запускать сервис с NOTES_DATA=./data/notes.txt. Наши обычные файлы данных лежат вне проекта (/var/lib/notes/ на сервере, /tmp/notes-dev.txt для make run), поэтому в репозиторий не попадают и так.

  3. Создай README.md проекта. README (читай: «прочти меня») это первая страница проекта, которую видит любой, кто открыл репозиторий. Отступ в четыре пробела перед командой это Markdown-оформление блока кода:

    cat > README.md <<'EOT'
    # Заметки
    
    Учебный HTTP-сервис на Python 3.12 или новее, без внешних зависимостей.
    
    ## Запуск
    
        python3 app.py
    
    Сервис слушает 127.0.0.1:8080. Проверка: `curl -i http://127.0.0.1:8080/healthz`.
    
    ## Тесты
    
        make test
    EOT
    
  4. Проверь, что будет закоммичено, и убедись, что секретов там нет. --ignored добавляет в git status --short игнорируемые файлы (значок !!), check-ignore -v с несколькими именами показывает, какое правило сработало для каждого:

    git status --short --ignored
    git check-ignore -v .env app.log __pycache__ data/notes.txt README.md
    git add .
    git status --short
    
    ?? .gitignore
    ?? Makefile
    ?? README.md
    ?? app.py
    ?? deploy/
    ?? requirements.txt
    ?? scripts/
    ?? test_app.py
    !! .env
    !! __pycache__/
    !! app.log
    .gitignore:1:.env	.env
    .gitignore:2:*.log	app.log
    .gitignore:3:__pycache__/	__pycache__
    .gitignore:5:data/	data/notes.txt
    A  .gitignore
    A  Makefile
    A  README.md
    A  app.py
    A  deploy/nginx/notes.conf
    A  deploy/ssh/99-notes.conf
    A  deploy/systemd/notes.service
    A  requirements.txt
    A  scripts/diagnose.sh
    A  scripts/notes-backup.sh
    A  scripts/notes-healthcheck.sh
    A  test_app.py
    

    Как читать вывод: ?? файлы и каталоги, которые git видит, но не отслеживает (каталог deploy/ показан целиком, пока не дошли до add). !! игнорируемые: .env, кэш и лог сюда не попадут. check-ignore для каждого имени назвал правило (.gitignore:2:*.log значит «строка 2»); README.md в списке нет, потому что он не игнорируется. После git add . все нужные файлы получили A (в индексе), а .env нет.

  5. Первый коммит и проверка:

    git commit -m "chore: первая версия проекта Заметки (app v3)"
    git log --oneline
    git ls-files
    git status --short --ignored
    
    [main (root-commit) 96dc85d] chore: первая версия проекта Заметки (app v3)
     12 files changed, 361 insertions(+)
     create mode 100644 .gitignore
     create mode 100644 Makefile
     create mode 100644 README.md
     create mode 100644 app.py
     create mode 100644 deploy/nginx/notes.conf
     create mode 100644 deploy/ssh/99-notes.conf
     create mode 100644 deploy/systemd/notes.service
     create mode 100644 requirements.txt
     create mode 100644 scripts/diagnose.sh
     create mode 100644 scripts/notes-backup.sh
     create mode 100644 scripts/notes-healthcheck.sh
     create mode 100644 test_app.py
    96dc85d chore: первая версия проекта Заметки (app v3)
    .gitignore
    Makefile
    README.md
    app.py
    deploy/nginx/notes.conf
    deploy/ssh/99-notes.conf
    deploy/systemd/notes.service
    requirements.txt
    scripts/diagnose.sh
    scripts/notes-backup.sh
    scripts/notes-healthcheck.sh
    test_app.py
    !! .env
    !! __pycache__/
    !! app.log
    

    Как читать вывод: 12 files changed число файлов в коммите (у тебя может отличаться: например, если пропущены некоторые файлы deploy/ или scripts/), а 361 insertions это все строки этих файлов, ведь для git первый коммит это «добавили всё». git ls-files печатает список отслеживаемых файлов: здесь всё из тем 1 и 2 (app.py, test_app.py, Makefile, README.md, .gitignore, requirements.txt, scripts/, deploy/), а .env, app.log, __pycache__/ нет. Последняя команда подтверждает: три игнорируемых остались на диске. Файл requirements.txt пустой, но git его хранит: для него это обычный файл с нулём строк. Пустые каталоги git не хранит, поэтому в списке только каталоги, где есть файлы.

Состояние проекта после урока: репозиторий ~/notes, один коммит на main, app.py версии v3.

Объясни себе:

  • Почему .gitignore создаётся раньше первого git add?
  • Почему в .gitignore стоит data/, а не сама база или файл заметок?
  • Что ты увидишь в git status, если появится новый файл app.log? А если новый файл notes.py?

Типичные ошибки:

  • hint: Using 'master' as the name for the initial branch.: не задан init.defaultBranch. Используй git init -b main или переименуй уже созданную ветку: git branch -m main.
  • .env всё равно в git status как изменённый ( M .env): он попал в индекс до .gitignore. Выполни git rm --cached .env, затем коммит (пароль в истории при этом остаётся, см. «Сломай и почини»).
  • warning: in the working copy of 'app.py', LF will be replaced by CRLF the next time Git touches it: так пишет git в Windows, когда проект лежит на диске Windows (в WSL2 это /mnt/c/...). Держи проект в домашнем каталоге Linux (~), а не в /mnt/c.
  • fatal: pathspec 'файл' did not match any files при git add: опечатка в имени файла или ты не в том каталоге. Проверь ls и pwd.

Если нейросеть предложила .gitignore, не вставляй его вслепую: сравни со своим списком файлов (git status --ignored) и проверь git check-ignore -v для .env.

Сломай и почини

Скачай и запусти учебный стенд. Скрипт создаёт отдельные учебные репозитории в каталоге ~/break-3.1, твои ~/notes и ~/git-lab не затрагиваются. Запускай без sudo, от обычного пользователя. Скрипт печатает только симптом, читать его не нужно.

curl -fsSL -o /tmp/break-3.1.sh https://raw.githubusercontent.com/distinguished-sre/learning/main/devops/project/notes/break/3.1/break.sh
bash /tmp/break-3.1.sh 1     # сценарий 1: потерянные коммиты; сценарий 2: секрет в коммите

Разбор команды: curl -fsSL -o файл адрес скачивает файл (-f не сохранять страницу ошибки, -s тихо, -S всё же показать ошибку, -L идти за перенаправлениями, -o куда сохранить). Сценарии запускай по одному. Когда закончишь, bash /tmp/break-3.1.sh fix удалит учебные репозитории (если стоял в них, выполни cd ~). Повторный запуск того же сценария ничего не ломает: скрипт скажет, что репозиторий уже создан. Сначала попробуй решить сам, ответ ниже спрятан.

Симптом

Сценарий 1 (bash /tmp/break-3.1.sh 1, репозиторий ~/break-3.1/lost): коллега написал: «Сделал git reset --hard и пропали два моих коммита с правкой в app.py, в git log их нет».

Сценарий 2 (bash /tmp/break-3.1.sh 2, репозиторий ~/break-3.1/leak): в истории есть коммит, где закоммичен файл .env с паролем. Репозиторий пока только локальный.

Гипотезы

  1. Коммиты физически удалены, вернуть нельзя, или на них просто ничто не указывает.
  2. (Сценарий 2) достаточно добавить .env в .gitignore или удалить файл новым коммитом.

Проверки

git reflog -10                    # где был HEAD до reset
git log --all --oneline -- .env   # в каких коммитах трогали .env
git ls-files | grep '^.env$'      # отслеживается ли сейчас

Перейди в нужный репозиторий (cd ~/break-3.1/lost или cd ~/break-3.1/leak) и запусти команды. Что ты увидишь. Сценарий 1: git log --oneline показывает один коммит (feat: первая версия app.py), а git reflog -10 четыре записи, среди них commit: fix: вернуть 405 на DELETE и reset: moving to HEAD~2. Хеши у тебя свои. Сценарий 2: git log --all --oneline -- .env находит коммит chore: добавить конфиг базы, а последняя команда печатает .env, то есть файл до сих пор отслеживается. Если ты видишь такое, значит, гипотеза 1 верна только в мягкой форме («ничто не указывает»), а гипотеза 2 неверна.

Исправление

Разбор сценариев

Сценарий 1. Верна вторая часть гипотезы 1: коммиты остались, на них просто ничто не указывает. Находишь строку в git reflog до reset, например HEAD@{1}, и создаёшь на ней ветку. Так безопаснее, чем двигать текущую: git branch rescue 'HEAD@{1}'. Проверка: git log --oneline rescue покажет все три коммита. Если хочешь вернуть их в main, выполни git reset --hard rescue. Реальный вывод:

$ git reflog -10
5cc201a HEAD@{0}: reset: moving to HEAD~2
04bd340 HEAD@{1}: commit: fix: вернуть 405 на DELETE
58fb62f HEAD@{2}: commit: feat: добавить /headers
5cc201a HEAD@{3}: commit (initial): feat: первая версия app.py
$ git branch rescue 'HEAD@{1}'
$ git log --oneline rescue
04bd340 fix: вернуть 405 на DELETE
58fb62f feat: добавить /headers
5cc201a feat: первая версия app.py

Урок: reset --hard уничтожает только незакоммиченное, а закоммиченное живёт в reflog, пока его не удалит сборка мусора.

Сценарий 2. Гипотеза 2 неверна: .gitignore не действует на отслеживаемые файлы, а удаление новым коммитом оставляет пароль в старых снимках (это показано в задании 5, шаг 1). Порядок реагирования:

  1. Ротировать секрет (rotation): считай пароль скомпрометированным, сгенерируй новый (например, openssl rand -base64 24) и замени там, где он использовался. Это главное действие: чистка истории не отзывает то, что уже скопировали.
  2. Убрать файл из индекса: git rm --cached .env, убедиться, что .env есть в .gitignore.
  3. Если история ещё не отправлена, переписать её так, чтобы файла не осталось ни в одном коммите. Самый простой способ: программа git filter-repo (в Ubuntu: sudo apt install git-filter-repo). Она отказывается работать в репозитории, который не похож на свежий клон, поэтому нужен --force:

    $ git filter-repo --path .env --invert-paths
    Aborting: Refusing to destructively overwrite repo history since
    this does not look like a fresh clone.
      (expected at most one entry in the reflog for HEAD)
    Please operate on a fresh clone instead.  If you want to proceed
    anyway, use --force.
    $ git filter-repo --path .env --invert-paths --force
    Parsed 3 commits
    ...
    $ git log --oneline
    70a6583 docs: добавить README
    5cc201a feat: первая версия app.py
    

    --path .env --invert-paths значит «все файлы, кроме .env». Коммит, который только добавлял .env, исчез целиком, хеши следующих коммитов изменились (напоминаю: хеш зависит от родителя). Поэтому если репозиторий уже отправлен в общий, чистка требует согласованного force-push и переклонирования у всех: про это в уроке 3.2.

  4. Добавить защиту, чтобы такое не повторилось: .gitignore до первого add и поиск секретов в CI (урок 3.4).

ИИ в помощь

Нейросеть быстро объяснит незнакомый вывод git и подскажет формулировку, но твоего репозитория она не видит: состояние ты показываешь ей сам, вставляя вывод команд. Общие правила: ИИ-помощник.

Задача: разобрать вывод git status и понять, что делать дальше.

Я учу git. Вот вывод команд в моём репозитории:
<вставь git status --short и git log --oneline -5>.
Объясни по строкам, что значит каждая колонка и в каком из трёх мест (рабочий каталог, индекс, история) лежат мои правки.
Предложи следующий шаг, но не выполняй ничего необратимого без предупреждения.

Проверь ответ: сверь колонки status с таблицей из раздела «Три области» и запусти git diff и git diff --staged, чтобы увидеть правки своими глазами. Типичная ошибка нейросетей: советовать git reset --hard или git push --force как «быстрое решение», не сказав, что правки без коммита пропадут навсегда.

Задача: получить варианты сообщения коммита по готовой правке.

Вот результат git diff --staged:
<вставь diff>.
Предложи три варианта сообщения коммита в стиле Conventional Commits: тип, двоеточие, суть в повелительном наклонении, заголовок до 72 символов.
Объясни, почему выбран именно этот тип (feat, fix, docs, chore).

Проверь ответ: прочитай diff ещё раз и проверь, что в сообщении нет того, чего в правке не было. Типичная ошибка: нейросеть подставляет тип feat для любой правки или придумывает причину («для ускорения»), которой в diff не видно.

Задача: составить .gitignore для проекта и проверить его.

Мой проект: Python-приложение без зависимостей, тесты на unittest, Makefile, локальный файл .env с паролями, логи app.log и каталог data/ с базой.
Составь .gitignore и объясни каждую строку.
Отдельно скажи, что произойдёт, если какой-то из этих файлов уже был закоммичен.

Проверь ответ: выполни git check-ignore -v .env app.log data/ и убедись, что каждое правило сработало, а git status не показывает лишнего. Типичная ошибка: нейросеть забывает, что .gitignore не действует на уже отслеживаемые файлы, и не предлагает git rm --cached; ещё она может добавить в список Makefile или сам .gitignore.

Словарик урока

Термин Простыми словами
git программа, которая хранит историю изменений проекта и позволяет к ней возвращаться
репозиторий (repository) каталог проекта вместе с историей в скрытом каталоге .git
рабочий каталог (working tree) обычные файлы проекта, которые ты видишь и правишь
индекс (index, staging area) «коробка на отправку»: что попадёт в следующий коммит
коммит (commit) сохранённый снимок всего проекта с автором, временем, сообщением и ссылкой на родителя
хеш (hash) отпечаток из 40 цифр и букв, вычисленный из содержимого; служит именем объекта
SHA-1 алгоритм, которым git считает хеши
объект (object) единица хранения в git: blob (файл), tree (каталог), commit
blob сохранённое содержимое одного файла без имени
tree список файлов и подкаталогов проекта в одном снимке
родитель (parent) коммит, из которого вырос данный (у слияния их два)
ветка (branch) имя (закладка), указывающее на один коммит и сдвигающееся с новым коммитом
HEAD указатель «где я сейчас»: обычно на ветку, иногда прямо на коммит
отсоединённый HEAD (detached HEAD) HEAD указывает на коммит, а не на ветку
слияние (merge) объединение ветки с текущей
fast-forward слияние без нового коммита: указатель ветки просто переезжает вперёд
merge commit коммит с двумя родителями, создаваемый при слиянии разошедшихся веток
конфликт (conflict) обе ветки изменили одни и те же строки, и git не может выбрать сам
restore вернуть файл (из индекса или коммита) без изменения истории
revert создать новый коммит, отменяющий указанный
reset сдвинуть ветку на другой коммит (--soft, --mixed, --hard различаются глубиной)
reflog локальный журнал перемещений HEAD, по нему находят «потерянные» коммиты
stash «карман»: временно убрать незакоммиченные правки
bisect двоичный поиск коммита, который сломал код
.gitignore файл со списком шаблонов файлов, за которыми git не следит
отслеживаемый файл (tracked) файл, который есть в индексе или последнем коммите
Conventional Commits соглашение о формате сообщений: тип: суть
diff разница между двумя версиями, строки с + и -
pager (less) программа постраничного просмотра, в которой открывается длинный вывод (выход клавишей q)
ротация секрета (rotation) замена скомпрометированного пароля или ключа на новый
инцидент (incident) сбой в работе сервиса; git log помогает найти, что и когда изменили
Terraform инструмент, описывающий серверы и сети текстовыми файлами (урок 7.1)
манифест (manifest) текстовый файл с описанием того, что запускать в Kubernetes (урок 5.1)
README.md текстовая «визитка» проекта: что это и как запустить

Вопросы с собеседований

Раздел для повторения: ответь вслух, потом открой ответ. Короткие вопросы с пометкой [на скорость] тренируй на время: ответ за 30 секунд.

1. [junior] [часто] Чем отличаются git reset --soft, --mixed и --hard?

Ответ

Все три двигают текущую ветку на указанный коммит, разница в том, что происходит с индексом и рабочим каталогом. --soft оставляет и индекс, и файлы: изменения остаются в staged. --mixed (режим по умолчанию) сбрасывает индекс, но файлы не трогает: правки на месте, только не добавлены. --hard сбрасывает и индекс, и файлы, незакоммиченные правки пропадают. Потерянный коммит после --hard ищу в git reflog. На общей ветке вместо reset делаю git revert.

Что хотят услышать: три области (каталог, индекс, история), что меняется в каждом режиме, reflog, revert для общих веток.

Красный флаг: «--hard просто откатывает коммит» без понимания, что он стирает незакоммиченные правки; reset на уже отправленной общей ветке.

2. [junior] [часто] Что такое три области git и что делают add и commit?

Ответ

Рабочий каталог (файлы, которые правишь), индекс (список того, что попадёт в следующий коммит) и история (сохранённые коммиты). git add копирует текущую версию файла в индекс, git commit превращает индекс в новый коммит. Двухшаговость нужна, чтобы из нескольких правок собирать осмысленные коммиты.

Что хотят услышать: индекс как промежуточная область, git status и git diff --staged.

Красный флаг: «add добавляет файл в git навсегда».

3. [junior] [часто] В чём разница между git revert и git reset?

Ответ

revert создаёт новый коммит, отменяющий указанный: история не переписывается, поэтому он годится для уже отправленных изменений. reset двигает ветку назад, коммиты «выпадают», поэтому его применяют только к локальной, никому не отданной работе. У reset три режима: --soft двигает только ветку, --mixed ещё и индекс, --hard ещё и файлы.

Что хотят услышать: историю нельзя переписывать после публикации, режимы soft/mixed/hard.

Красный флаг: не различает и советует reset --hard для отправленного коммита.

4. [junior] [на скорость] Что такое git и чем он отличается от GitHub?

Ответ

Git это программа, которая ведёт историю изменений проекта на твоём компьютере: хранит снимки (коммиты), ветки, позволяет откатывать и сравнивать. GitHub это сайт-сервис, где хранят копии репозиториев и совместно работают над ними. Пользоваться git можно без GitHub, история хранится локально, в каталоге .git.

Что хотят услышать: git локальный и распределённый, GitHub одна из площадок (есть GitLab и другие).

Красный флаг: «git и GitHub это одно и то же».

5. [junior] Ты сделал git reset --hard HEAD~3 и понял, что сбросил нужное. Что делаешь?

Ответ

Смотрю git reflog: там записан каждый сдвиг HEAD. Нахожу хеш состояния до reset и делаю git reset --hard <хеш> или создаю ветку git branch rescue <хеш>. Это работает, пока объекты не удалены сборкой мусора. Незакоммиченные правки таким способом не вернуть, они нигде не записаны.

Что хотят услышать: reflog, ветка для спасения, оговорка про незакоммиченное и про то, что reflog локальный.

Красный флаг: «всё потеряно, надо писать заново».

6. [junior] Ты закоммитил .env с паролем БД, ещё не отправив. Что делаешь?

Ответ

Первым делом считаю пароль скомпрометированным и меняю его. Потом git rm --cached .env, добавляю файл в .gitignore, переписываю историю (git filter-repo или интерактивный rebase), чтобы файла не осталось в снимках. Позже добавляю сканер секретов в CI.

Что хотят услышать: ротация секрета раньше чистки истории, .gitignore не действует на отслеживаемое.

Красный флаг: «удалю файл новым коммитом и всё».

7. [junior] [на скорость] Добавил файл в .gitignore, а он всё равно отслеживается. Почему?

Ответ

Потому что файл уже в индексе, а .gitignore влияет только на неотслеживаемые файлы. Нужно git rm --cached файл и коммит, на диске файл останется. Проверить правило можно командой git check-ignore -v файл.

Что хотят услышать: --cached, check-ignore -v.

Красный флаг: предлагает удалить файл и создать заново.

8. [middle] Чем fast-forward слияние отличается от merge commit? Когда какое получится?

Ответ

Fast-forward возможен, если у целевой ветки нет собственных новых коммитов: указатель просто сдвигается, новых коммитов нет. Если ветки разошлись, создаётся merge commit с двумя родителями. Принудительно merge commit даёт --no-ff, только fast-forward разрешает --ff-only (слияние откажется, если перемотка невозможна).

Что хотят услышать: родители, --ff-only в скриптах, ромб в log --graph.

Красный флаг: «merge всегда создаёт коммит».

9. [middle] Срочно нужен хотфикс, а у тебя полфайла незакоммиченных правок. Как быть?

Ответ

git stash (с ключом -u, если есть новые файлы), затем git switch -c hotfix main, чиню, коммичу, возвращаюсь на прежнюю ветку и делаю git stash pop. Для чего-то ценного или долгого лучше временный коммит в отдельной ветке: стеш легко забыть, а коммит виден в истории и защищён reflog.

Что хотят услышать: stash -u, конфликт при pop, альтернатива через WIP-коммит (временный коммит «work in progress»).

Красный флаг: «закоммичу всё в main как есть».

10. [middle] Тесты позавчера проходили, сегодня падают, между ними 200 коммитов. Как найти виновного?

Ответ

git bisect: отмечаю bad для текущего коммита и good для позавчерашнего, дальше двоичный поиск: примерно 8 проверок на 200 коммитов (2 в восьмой степени это 256). Лучше автоматически: git bisect run со скриптом, где код возврата 0 значит «хорошо», а 1-127 (кроме 125) «плохо»; 125 значит «этот коммит проверить нельзя, пропусти». В конце git bisect reset.

Что хотят услышать: логарифмическое число шагов, bisect run, код 125, reset в конце.

Красный флаг: «просмотрю все коммиты по очереди».

11. [middle] Что такое detached HEAD и как из него не потерять работу?

Ответ

HEAD указывает на коммит, а не на ветку. Новые коммиты не принадлежат ни одной ветке, и после переключения на другую ветку они «висят» без ссылок. Если сделал работу, создаю ветку прямо там: git switch -c имя. Если уже ушёл, ищу коммит в reflog (git и сам предупреждает и печатает хеш) и создаю ветку на нём.

Что хотят услышать: связь с reflog, git switch -c, что это нормальное состояние, а не поломка.

Красный флаг: «это ошибка, надо переустановить git».

12. [middle] Ты закоммитил в main, а надо было в ветку. Коммит не отправлен. Что делаешь?

Ответ

git branch feature (новая ветка на тот же коммит, работа теперь под защитой), затем git reset --hard HEAD~1 на main, чтобы вернуть её на прежний коммит, и git switch feature. Если коммит уже отправлен и коллеги на него опираются, main не трогаю, а делаю revert.

Что хотят услышать: ветка создаётся до reset, критерий «отправлено или нет».

Красный флаг: копировать файлы руками.

13. [junior] [на скорость] Как отменить незакоммиченные правки в файле и как убрать файл из индекса, не потеряв правки?

Ответ

Сначала смотрю, что именно отменяю: git diff для рабочего каталога, git diff --staged для индекса. git restore файл возвращает файл к версии из индекса, и незакоммиченные правки пропадают безвозвратно. git restore --staged файл только убирает файл из индекса, правки в рабочем каталоге остаются. Раньше для этого использовали git checkout -- файл и git reset файл, но restore (с Git 2.23) понятнее.

Что хотят услышать: restore против restore --staged, незакоммиченное не восстановить, сначала diff.

Красный флаг: Запускать git checkout . или git reset --hard, не глядя, что потеряется.

14. [middle] Ты опечатался в сообщении последнего коммита или забыл добавить в него файл. Что делаешь?

Ответ

Если коммит ещё не отправлен: для сообщения git commit --amend -m "новое сообщение". Для файла сначала git add файл, потом git commit --amend --no-edit. --amend создаёт новый коммит с новым хешем, а старый перестаёт быть на ветке. Если коммит уже в общей ветке, не переписываю его, а делаю отдельный коммит. В своей ветке после push понадобится --force-with-lease.

Что хотят услышать: --amend меняет хеш, то есть переписывает историю, можно только для неотправленного или своего коммита.

Красный флаг: Амендить коммит, который уже забрали коллеги, и пушить --force.

Проверено на версиях

  • git: 2.43.0 (Ubuntu 24.04, контейнер на Docker) и 2.53.0 (Ubuntu 26.04): задания 1-4 выполнены на обеих версиях. Отличия в выводе: в 26.04 нет каталога .git/branches, а отказ git branch -d дополнен строками hint:. Остальной вывод совпал. Использованные команды (switch, restore, stash, bisect run) есть в git начиная с 2.23, на более старых версиях не прогонялись.
  • Задание 5 и скрипт break.sh (сценарии 1, 2 и fix, двойной запуск каждого): Ubuntu 24.04, git 2.43.0. shellcheck без замечаний. На Ubuntu 26.04 задание 5 и скрипт не прогонялись.
  • git filter-repo 2.38.0 (пакет git-filter-repo в Ubuntu 24.04): проверен сценарий очистки истории из раздела «Сломай и почини».
  • Python 3.12.3 и make test (6 тестов, OK): Ubuntu 24.04, app.py v3 из project/notes/versions/v3.py. На 26.04 с Python 3.14 не прогонялось.
  • Не проверялось: поведение git на zsh и на Windows/WSL2 (предупреждение про CRLF приведено по документации git).

Итог урока: ты умеешь

  • пройти файл через рабочий каталог, индекс и историю и показать каждое состояние командами status и diff
  • объяснить, что коммит это снимок с родителем и хешем, а не «разница»
  • вести осмысленную историю с сообщениями в стиле Conventional Commits
  • создавать ветки, сливать их и отличать fast-forward от merge commit
  • выбирать между restore, revert и reset и объяснять почему
  • вернуть «потерянный» коммит и отсоединённую работу через reflog
  • найти сломавший коммит через git bisect run
  • настроить .gitignore и знать, что делать, если секрет попал в коммит
  • инициализировать проект «Заметки» как репозиторий с первым коммитом

Дальше: Урок 3.2: Удалённые репозитории, rebase, конфликты и Pull Request

Проверь себя

Короткий тест по уроку: 5 вопросов из банка в 30. Засчитывается только полностью правильный ответ, порог 60%. Каждая новая попытка даёт другие вопросы, пока банк не закончится. Ответы видны после проверки.

Тест работает с включённым JavaScript.

тема 3 урок 3.1 4 ч курс 0/0 ← → уроки