Перейти к содержанию

Git: основы — квиз для самопроверки

Компаньон к мануалу 00 про Git. Сначала попробуйте ответить на все вопросы самостоятельно, потом сверьтесь с разделом «Ответы». Не подглядывайте раньше времени — так проверка честнее.


Вопросы для самопроверки

  1. Что такое DVCS и чем она принципиально отличается от централизованной модели (CVCS, например SVN)? Какое из трёх ключевых свойств Git, описанных в мануале, отвечает за то, что коммит, ветвление и просмотр истории работают без сети?

  2. В Git есть три зоны, а не две (файлы и репозиторий). Что такое индекс (staging area) и зачем он нужен? Объясните на примере: вы правите пять файлов, но хотите закоммитить только два осмысленных изменения — какими командами это сделать и почему без индекса это было бы невозможно?

  3. Объясните разницу между веткой, HEAD, тегом и origin. Что произойдёт, если вы сделаете новый коммит, находясь в состоянии detached HEAD, а потом переключитесь на другую ветку без git switch -c?

  4. Из каких трёх основных типов объектов (не считая тегов) состоит история Git, и что каждый из них хранит? Опираясь на объектную модель, объясните: почему Git технически не «хранит переименования», а определяет их эвристикой, и почему нельзя закоммитить пустой каталог.

  5. Сценарий. Вы правите разом три несвязанные вещи в одном файле: опечатку в комментарии, рефакторинг функции и фикс реального бага. Какой инструмент из мануала позволит закоммитить это как три отдельных осмысленных коммита, а не один «на всякий случай»? Почему атомарные коммиты важны для git blame и git bisect?

  6. В чём разница между fast-forward и three-way merge? По какому признаку Git решает, какой из двух сценариев применить, и что в итоге отличает их в графе истории (в частности — сколько родителей будет у результата)?

  7. Сценарий. Ветка feature/payment-retry живёт три дня, main за это время ушёл вперёд на несколько коммитов. Перед тем как открыть Pull Request, вы хотите получить линейную историю без лишнего merge-коммита. Какую операцию вы выполните, и почему делать её можно только потому, что ветка ещё не опубликована (не отправлена в общий remote)? Что означает «золотое правило» из мануала применительно к этой ситуации?

  8. Чем git fetch принципиально отличается от git pull? Что такое remote-tracking ветка (origin/main) и почему она не двигается сама по себе? Что покажет git branch -vv и зачем это нужно перед началом работы над задачей?

  9. Сценарий. Коллега на общей ветке main увидел ошибку в истории и сделал git push --force, затерев два чужих коммита, о существовании которых не знал. Какая команда должна была защитить его от этой ошибки, и как она работает? Как правильно отменить уже опубликованный (запушенный) коммит без переписывания истории?

  10. Сценарий. Сопоставьте ситуацию с правильной командой и объясните выбор: (а) нужно отменить правки в файле в рабочем каталоге, ничего не коммитили; (б) файл добавлен в индекс по ошибке, но правки в нём нужно оставить; (в) последний коммит не отправлен, нужно убрать его, но сохранить изменения в рабочем каталоге; (г) коммит уже давно в main на сервере, и его нужно отменить.

  11. Что такое reflog и почему это «страховка от почти любой ошибки»? Приведите два ограничения reflog, из-за которых на него нельзя полагаться безоговорочно (подсказка: что происходит со свежим клоном и с незакоммиченными изменениями).

  12. Почему добавление уже закоммиченного файла в .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 ` — она создаёт новый коммит, отменяющий изменения, не трогая существующую историю; это единственный корректный способ отмены в общей ветке, в отличие от `git reset`, который двигает указатель назад и переписывает историю. 10. (а) `git restore <файл>` — откатывает рабочий каталог к версии из индекса/HEAD. (б) `git restore --staged <файл>` — убирает файл из индекса, изменения в рабочем каталоге не трогает. (в) `git reset HEAD~1` (режим `--mixed`, он же по умолчанию) — сдвигает ветку назад на коммит, изменения остаются в рабочем каталоге (незакоммиченными); если нужно оставить их именно в индексе — `git reset --soft HEAD~1`. (г) `git revert ` — добавляет новый обратный коммит, не переписывая опубликованную историю; `git reset` для уже отправленного в общую ветку коммита недопустим. 11. Reflog — локальный журнал всех перемещений HEAD и веток, включая коммиты, на которые уже ничего не ссылается (например, после `git reset --hard`). Он позволяет найти «потерянный» коммит по его хешу в reflog и вернуться к нему (`git reset --hard ` или `git switch -c rescue `). Два ограничения: (1) reflog **локален** — в свежем клоне репозитория его вообще нет, он не передаётся по сети; (2) он не вечен (записи по умолчанию живут 90/30 дней) и, что важнее, не спасает то, что никогда не попадало в Git — незакоммиченные правки, снесённые `git reset --hard` или `git clean -fd`, восстановить нельзя, потому что о них никогда не было объекта в `.git`. 12. Правила `.gitignore` действуют только на **неотслеживаемые** файлы — если файл уже был закоммичен раньше, Git продолжает следить за ним независимо от того, что написано в `.gitignore`. Чтобы прекратить отслеживание, но оставить файл на диске, нужно `git rm --cached <путь>` и затем коммит этого изменения. `.gitignore` коммитится в репозиторий, потому что он описывает общие для всей команды артефакты (сборка, логи, секреты по шаблону) — это часть проекта; личные же исключения (файлы конкретной IDE конкретного разработчика) не должны навязываться всей команде и хранятся локально — в `~/.config/git/ignore` (глобально для пользователя) или `.git/info/exclude` (локально для репозитория, не попадает в историю).

Практическое задание

Задание 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 (или новый) с файлом, в котором есть хотя бы одна строка кода/конфига, которую легко менять в двух разных направлениях (например, значение таймаута или количества ретраев).

Цель.

  1. От общего коммита создайте две ветки: main (или продолжайте на ней) и feature/x. В main измените строку в одну сторону и закоммитьте, в feature/x — в другую сторону и закоммитьте.
  2. Слейте feature/x в main через git merge и разрешите конфликт по шагам из мануала (git status --short, правка маркеров <<<<<<</ =======/>>>>>>>, git add, git commit). Убедитесь, что получившийся коммит имеет двух родителей.
  3. Откатите этот эксперимент (например, работая в копии репозитория или сбросив 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, попытка восстановить незакоммиченные, а не закоммиченные изменения).