✻ Урок 3.1 · Тема 3: Git и CI
Git: коммиты, история и ветки
Содержание урока
Зачем это нужно
Ты правишь 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.
Что нужно знать
- Урок 1.1: терминал и файловая система:
cd,ls,mkdir,cat, справкаmanи--help. Каталог (directory) это папка, файл это именованный кусок данных на диске. - Урок 1.2: текст, потоки и конвейеры:
grep,>и>>(записать вывод в файл и дописать в конец),|(передать вывод одной команды другой), код возврата$?. - Урок 1.6: основы bash:
Makefile(файл с короткими именами для длинных команд) иtest_app.py(тесты приложения) в~/notes, они попадут в первый коммит. - Урок 2.4: HTTP: в
~/notesлежитapp.pyверсии 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). Поэтому осмысленность истории зависит от тебя.
Идеи тут три:
- Снимки. Git фиксирует не «что изменилось», а состояние всего проекта в момент коммита, как фотографию.
- Распределённость. Полная история лежит у каждого, кто работает с проектом, а не в одном месте. Поэтому
git log, откат и ветки работают без сети. Как обмениваться историей с другими, разберём в уроке 3.2. - Целостность. Каждое состояние получает имя, вычисленное из содержимого (хеш, об этом ниже). Незаметно подменить старую версию нельзя.
Без 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 выполняет такие шаги по порядку:
- Сравнивает снимок текущего коммита со снимком целевой ветки: какие файлы различаются.
- Проверяет, нет ли у тебя незакоммиченных правок в тех файлах, которые предстоит перезаписать. Если есть, останавливается с ошибкой, и ничего не меняется.
- Переписывает в рабочем каталоге и индексе только различающиеся файлы: одни появляются, другие исчезают, третьи меняют содержимое.
- Записывает в
.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» (в индексе).
Шаги:
-
Создай репозиторий и файл. Разбор:
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 в скобках полезно: в них ровно нужная команда. -
Добавь в индекс и закоммить.
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--. -
Измени файл и сравни области. Команда
>>дописывает строку в конец: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показал ту же правку: она переехала из «рабочий против индекса» в «индекс против коммита». -
Загляни внутрь коммита и в
.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 | sortcommit 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 имя файла. -
Посмотри
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.txtb2049a7 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 с двумя родителями.
Шаги:
-
Ветка
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. -
Вернись в
mainи слей. Заметь, чтоslow.txtвmainне было (lsего не покажет), а после слияния появится:git switch main ls git merge feature/slow git log --oneline --graphSwitched 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, новых коммитов нет, история осталась прямой линией. -
Теперь настоящее расхождение: обе ветки получают по коммиту. Ключ
--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 HEADSwitched 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. -
Удали слитые ветки, потом попробуй удалить неслитую (
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 experimentDeleted 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). -
Отсоединённый
HEAD. Переключись на коммит (не на ветку), сделай в нём коммит и уйди, потом спаси работу.git switch --detach HEAD~1 git status echo 'GET /detached' > detached.txt git add detached.txt && git commit -m "feat: работа без ветки" git switch mainHEAD 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.
Шаги:
-
Отмена правок до коммита.
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.txtM api.txt M api.txt M api.txtКак читать вывод: первая строка ` M api.txt
(правка есть, в индексе нет). Послеrestore api.txtпусто: правка отменена. ДальшеM ` (правка в индексе), послеrestore --stagedснова ` M(правка вернулась в рабочий каталог, но не в индекс), последнийrestore` окончательно сбросил файл. -
Отмена коммита без переписывания истории.
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. -
Три режима
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у тебя будет другим. -
Теперь спасение закоммиченной работы. Коммитим заново, теряем через
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.txtHEAD 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, потому что она была закоммичена. -
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.txtSaved 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Как читать вывод: после
stashgit 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): каждый шаг делит диапазон пополам.
Шаги:
-
Сгенерируй историю из 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 --oneline12 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 -
Напиши проверку. Она возвращает код 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не закоммичен (??), поэтому при переключении между коммитами он остаётся на месте и не мешает. -
Запусти автоматический 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 resetstatus: 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'Вывод у тебя будет тем же по смыслу, а хеши и время другими.
Как читать вывод: после
goodgit сам переключился на середину диапазона (коммит 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.
Шаги:
-
Сначала ловушка на учебном репозитории (безопасно: пароль выдуманный). Разбор:
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`. Хеши у тебя будут другими. -
Теперь проект. Создай в
~/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), поэтому в репозиторий не попадают и так. -
Создай
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 -
Проверь, что будет закоммичено, и убедись, что секретов там нет.
--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нет. -
Первый коммит и проверка:
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 с паролем. Репозиторий пока только локальный.
Гипотезы
- Коммиты физически удалены, вернуть нельзя, или на них просто ничто не указывает.
- (Сценарий 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). Порядок реагирования:
- Ротировать секрет (rotation): считай пароль скомпрометированным, сгенерируй новый (например,
openssl rand -base64 24) и замени там, где он использовался. Это главное действие: чистка истории не отзывает то, что уже скопировали. - Убрать файл из индекса:
git rm --cached .env, убедиться, что.envесть в.gitignore. -
Если история ещё не отправлена, переписать её так, чтобы файла не осталось ни в одном коммите. Самый простой способ: программа
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. - Добавить защиту, чтобы такое не повторилось:
.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-repo2.38.0 (пакетgit-filter-repoв Ubuntu 24.04): проверен сценарий очистки истории из раздела «Сломай и почини».- Python 3.12.3 и
make test(6 тестов,OK): Ubuntu 24.04,app.pyv3 из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.