Git: основы — квиз для самопроверки¶
Компаньон к мануалу 00 про Git. Сначала попробуйте ответить на все вопросы самостоятельно, потом сверьтесь с разделом «Ответы». Не подглядывайте раньше времени — так проверка честнее.
Вопросы для самопроверки¶
-
Что такое DVCS и чем она принципиально отличается от централизованной модели (CVCS, например SVN)? Какое из трёх ключевых свойств Git, описанных в мануале, отвечает за то, что коммит, ветвление и просмотр истории работают без сети?
-
В Git есть три зоны, а не две (файлы и репозиторий). Что такое индекс (staging area) и зачем он нужен? Объясните на примере: вы правите пять файлов, но хотите закоммитить только два осмысленных изменения — какими командами это сделать и почему без индекса это было бы невозможно?
-
Объясните разницу между веткой, HEAD, тегом и
origin. Что произойдёт, если вы сделаете новый коммит, находясь в состоянии detached HEAD, а потом переключитесь на другую ветку безgit switch -c? -
Из каких трёх основных типов объектов (не считая тегов) состоит история Git, и что каждый из них хранит? Опираясь на объектную модель, объясните: почему Git технически не «хранит переименования», а определяет их эвристикой, и почему нельзя закоммитить пустой каталог.
-
Сценарий. Вы правите разом три несвязанные вещи в одном файле: опечатку в комментарии, рефакторинг функции и фикс реального бага. Какой инструмент из мануала позволит закоммитить это как три отдельных осмысленных коммита, а не один «на всякий случай»? Почему атомарные коммиты важны для
git blameиgit bisect? -
В чём разница между fast-forward и three-way merge? По какому признаку Git решает, какой из двух сценариев применить, и что в итоге отличает их в графе истории (в частности — сколько родителей будет у результата)?
-
Сценарий. Ветка
feature/payment-retryживёт три дня,mainза это время ушёл вперёд на несколько коммитов. Перед тем как открыть Pull Request, вы хотите получить линейную историю без лишнего merge-коммита. Какую операцию вы выполните, и почему делать её можно только потому, что ветка ещё не опубликована (не отправлена в общий remote)? Что означает «золотое правило» из мануала применительно к этой ситуации? -
Чем
git fetchпринципиально отличается отgit pull? Что такое remote-tracking ветка (origin/main) и почему она не двигается сама по себе? Что покажетgit branch -vvи зачем это нужно перед началом работы над задачей? -
Сценарий. Коллега на общей ветке
mainувидел ошибку в истории и сделалgit push --force, затерев два чужих коммита, о существовании которых не знал. Какая команда должна была защитить его от этой ошибки, и как она работает? Как правильно отменить уже опубликованный (запушенный) коммит без переписывания истории? -
Сценарий. Сопоставьте ситуацию с правильной командой и объясните выбор: (а) нужно отменить правки в файле в рабочем каталоге, ничего не коммитили; (б) файл добавлен в индекс по ошибке, но правки в нём нужно оставить; (в) последний коммит не отправлен, нужно убрать его, но сохранить изменения в рабочем каталоге; (г) коммит уже давно в
mainна сервере, и его нужно отменить. -
Что такое reflog и почему это «страховка от почти любой ошибки»? Приведите два ограничения reflog, из-за которых на него нельзя полагаться безоговорочно (подсказка: что происходит со свежим клоном и с незакоммиченными изменениями).
-
Почему добавление уже закоммиченного файла в
.gitignoreне убирает его из репозитория? Как правильно перестать отслеживать файл, оставив его на диске? Почему сам.gitignoreстоит коммитить в репозиторий, а личные исключения (файлы вашей IDE) — нет?
Ответы (открыть после самопроверки)
1. **DVCS** (Distributed VCS) — у каждого участника есть полная копия истории проекта, а не только рабочая копия файлов, как в **CVCS** (Centralized VCS, например SVN/Perforce), где история хранится на одном сервере. За офлайн-работу отвечает свойство **распределённости**: коммит, ветвление, `git log`, `git diff`, откат — всё это операции с локальным `.git`, сети не требуется. Сеть нужна только для `fetch`/ `push`/`clone`. 2. Индекс — промежуточная зона между рабочим каталогом и репозиторием, которая хранит *то, что войдёт в следующий коммит*, а не то, что лежит на диске. Благодаря этому коммит не обязан совпадать с состоянием рабочего каталога. Практически: `git add -p` позволяет добавить в индекс только нужные куски (hunk'и) — например, фикс бага, — закоммитить их (`git commit -m "..."`), а опечатку и рефакторинг оставить в рабочем каталоге для отдельных коммитов позже. Без индекса `git commit` зафиксировал бы вообще всё, что изменилось на диске, — разложить пять правок на осмысленные коммиты было бы нечем. 3. **Коммит** — снимок дерева файлов с метаданными; **ветка** — подвижный указатель (файл с хешем в `refs/heads/`) на один такой коммит; **HEAD** — указатель на то, где вы находитесь сейчас (обычно ссылка на ветку, а не на коммит напрямую); **тег** — неподвижная метка на конкретном коммите (в отличие от ветки, никуда не сдвигается сама); **origin** — просто имя по умолчанию для URL удалённого репозитория, не «главный сервер» в каком- то особом смысле. Если сделать коммит в detached HEAD и затем переключиться на другую ветку без `git switch -c`, новый коммит не привязан ни к одной ветке — он остаётся достижимым только через reflog и легко «теряется» из вида (хотя физически не удаляется до сборки мусора). 4. Три типа объектов: **blob** — содержимое файла (только байты, без имени и прав), **tree** — каталог: список имён/прав со ссылками на blob'ы и вложенные tree, **commit** — ссылка на корневой tree, родителя(ей) и метаданные автора. Имя файла хранится в tree, а не в blob, поэтому «переименование» — это просто новая запись в tree, ссылающаяся на тот же blob; Git не хранит операцию переименования явно, а *обнаруживает* её постфактум сравнением содержимого. Пустой каталог не может быть закоммичен, потому что tree — это список файлов, а не самостоятельная сущность: каталог без единого файла просто не существует как объект. 5. Инструмент — `git add -p` (интерактивный staging по кускам). Он позволяет добавить в индекс, например, только строки с фиксом бага, закоммитить их отдельно, затем повторить для рефакторинга и для правки опечатки — вместо одного «solves everything» коммита. Атомарные коммиты критичны для `git blame` (показывает, в каком именно коммите появилась строка — если коммит маленький, причина ошибки очевидна) и `git bisect` (бинарный поиск сломавшего коммита работает быстро и надёжно только тогда, когда каждый коммит меняет одну логическую вещь, а не всё разом). 6. **Fast-forward** происходит, когда в целевой ветке (например, `main`) с момента расхождения не появилось новых коммитов — тогда «слияние» это просто перемещение указателя ветки вперёд, без создания нового объекта; родитель у последнего коммита остаётся один. **Three-way merge** происходит, когда обе ветки получили собственные коммиты после общего предка (merge base) — Git создаёт новый merge-коммит с **двумя родителями**, соединяющий обе линии истории. Git выбирает автоматически: если fast-forward возможен, `git merge` сделает именно его, если не форсировать `--no-ff`. 7. Нужен `git rebase main`, находясь в `feature/payment-retry`: он берёт коммиты ветки, создаёт их копии поверх текущей вершины `main` и переставляет ветку на них — история становится линейной, как будто работа началась от актуального `main`. Это допустимо только потому, что коммиты ветки ещё не видел никто, кроме автора: rebase физически создаёт новые коммиты с новыми хешами, а старые становятся недостижимыми (пока их не подчистит сборка мусора). «Золотое правило» — не переписывать историю, которую уже видели другие: если ветка уже была отправлена в общий remote и кто-то мог из неё что-то тянуть, rebase создаст рассинхронизацию хешей у всех, кто уже получил старые коммиты. 8. `git fetch` только скачивает объекты и обновляет remote-tracking ссылки (`origin/main`), не трогая рабочую ветку и файлы — он безопасен и неинтерактивен. `git pull` — это `fetch` + `merge`/`rebase` в текущую ветку, то есть он ещё и меняет её состояние и может создать merge-коммит или конфликт. Remote-tracking ветка `origin/main` — это локальный «слепок» того, где была `main` на сервере на момент последнего fetch; она обновляется только по `fetch`/`push`, а не автоматически, — если она «не двигается», значит просто не было `fetch`. `git branch -vv` показывает список локальных веток вместе с их upstream-веткой и расхождением («ahead 2, behind 1»), что позволяет перед началом работы увидеть, не отстала ли ветка от удалённой без лишнего `git log`. 9. Защитить должна была команда `git push --force-with-lease` вместо `git push --force`: она отказывается перезаписывать удалённую ветку, если на сервере появилось что-то, чего локальный клиент ещё не видел (то есть ровно тот случай — чужие коммиты, о которых не знали). Уже опубликованный коммит отменяется командой `git revertПрактическое задание¶
Задание 1. Пройдите полный ежедневный цикл с нуля¶
Стартовая точка. Пустая папка на диске, ничего не инициализировано.
Цель. Создать локальный репозиторий и осознанно проделать в нём полный цикл, описанный в разделе «Ежедневная работа подробно»:
- настроить
user.name/user.emailиinit.defaultBranch(глобально или разово черезgit init -b main); - создать
.gitignoreдо первого коммита (используйте пример из мануала как основу, адаптировав под свой стек); - сделать 3-4 маленьких, атомарных коммита с сообщениями в императиве, а не
один большой — при необходимости используя
git add -p, если правите сразу несколько несвязанных вещей; - один из коммитов исправить через
git commit --amend(например, дописать забытый файл); - создать ветку, сделать в ней ещё пару коммитов, вернуться на исходную
ветку и посмотреть на обе истории через
git log --oneline --graph --decorate --all.
Что значит «готово». git log --oneline --graph --decorate --all
показывает читаемый граф из нескольких осмысленных коммитов с понятными
сообщениями; git status --short в конце работы чист (никаких
случайно неотслеживаемых файлов, которые должны были попасть в
.gitignore); вы можете объяснить по git cat-file -p HEAD и
git cat-file -p HEAD^{tree}, что физически лежит в последнем коммите.
Задание 2. Смоделируйте и разрешите конфликт слияния — сначала через merge, потом через rebase¶
Стартовая точка. Репозиторий из задания 1 (или новый) с файлом, в котором есть хотя бы одна строка кода/конфига, которую легко менять в двух разных направлениях (например, значение таймаута или количества ретраев).
Цель.
- От общего коммита создайте две ветки:
main(или продолжайте на ней) иfeature/x. Вmainизмените строку в одну сторону и закоммитьте, вfeature/x— в другую сторону и закоммитьте. - Слейте
feature/xвmainчерезgit mergeи разрешите конфликт по шагам из мануала (git status --short, правка маркеров<<<<<<</=======/>>>>>>>,git add,git commit). Убедитесь, что получившийся коммит имеет двух родителей. - Откатите этот эксперимент (например, работая в копии репозитория или
сбросив
mainдо состояния перед слиянием черезreflog) и повторите расхождение, но на этот раз выполнитеgit rebase main, находясь вfeature/x, разрешите тот же конфликт черезgit rebase --continue, и сравните получившуюся историю.
Что значит «готово». После шага 2 git log --graph показывает
merge-коммит с двумя родителями (git show <sha> подтверждает два
parent). После шага 3 история линейна — тех же изменений, но без
merge-коммита, а хеши коммитов feature/x изменились по сравнению с
оригиналом (это и есть отличительный признак rebase). Вы можете вслух
объяснить, почему один и тот же по смыслу конфликт в этих двух сценариях
разрешается технически одинаково (маркеры те же), но даёт разный результат
в истории.
Задание 3. Восстановите потерянные коммиты через reflog (по желанию)¶
Стартовая точка. В репозитории из предыдущих заданий сделайте 2-3
коммита в отдельной ветке, изображающих готовую фичу. Затем, «ошибочно»,
выполните git reset --hard HEAD~3 (или аналогичную команду, которая
отбрасывает эти коммиты) — представьте, что это сделал невнимательный
коллега, случайно перепутавший ветку.
Цель. Не используя git log (в котором потерянные коммиты уже не
видны), восстановить их с помощью git reflog: найти хеш коммита с фичей до
отката и вернуть его в дерево — либо жёстким git reset --hard <sha>, либо
аккуратно, вытащив в новую ветку git switch -c rescue <sha>, чтобы не
потерять то, что было на месте до восстановления.
Что значит «готово». У вас есть краткое объяснение (2-3 предложения),
какая запись в git reflog соответствует состоянию «до катастрофы» и почему
именно она; ветка rescue (или восстановленная исходная ветка) содержит все
2-3 «потерянных» коммита, что подтверждается git log --oneline на ней. При
желании — сформулируйте, при каких условиях этот трюк бы не сработал (свежий
клон без reflog, истечение gc.reflogExpireUnreachable, попытка
восстановить незакоммиченные, а не закоммиченные изменения).