Коммит хранит снимок всего проекта вместе с родительскими коммитами, автором и сообщением, и идентифицируется хешем от всего этого. Ветка представляет собой подвижное имя, указывающее на один коммит, а HEAD отмечает то, что у вас сейчас извлечено, обычно ветку.
Коммит хранит всё дерево, а не дифф, и его хеш покрывает ещё и родителей, поэтому изменение старого коммита меняет все хеши после него: вот почему переписывание запушенной истории создаёт проблемы.
Ветка всего лишь указатель: ref в .git/refs/heads/ с одним хешем, который каждый новый коммит сдвигает вперёд, поэтому ветка создаётся мгновенно.
**HEAD** обычно содержит ref: refs/heads/main, поэтому новый коммит двигает эту ветку.
Detached HEAD: HEAD хранит хеш коммита напрямую, после git checkout v1.4.0 или во время rebase и bisect. Коммиты, сделанные в этом состоянии, не принадлежат ни одной ветке: перед переключением выполните git switch -c fix/login или найдите их потом в git reflog.
ReactJunior
Git и командная работа
Merge и rebase, конфликты, reset и revert, cherry-pick, stash, reflog и bisect, стратегии ветвления, pull request и код-ревью, соглашения о коммитах, секреты в истории, lockfile и .env-файлы
Закрыто вопросов
0 / 18
- 0Повторить
- 3Часто спрашивают
Вопросы
git fetch скачивает новые коммиты и сдвигает ветки удалённого отслеживания вроде origin/main, но никогда не трогает вашу ветку и ваши файлы. git pull выполняет fetch, а затем merge (или rebase) вышестоящей ветки в текущую.
Fetch всегда безопасен: выполните его, а затем git log main..origin/main, чтобы увидеть, что пришло, до того как это вливать.
Обычный pull делает merge: если новые коммиты есть у обеих сторон, появляется коммит «Merge branch 'main' of …», который ничего не говорит о работе.
**git pull --rebase** переигрывает ваши незапушенные коммиты поверх скачанных и сохраняет историю прямой; git config --global pull.rebase true делает это поведением по умолчанию.
Ветки разошлись, а настройка не задана: современный Git останавливается с fatal: Need to specify how to reconcile divergent branches., пока вы не выберете pull.rebase или pull.ff only.
Merge соединяет две истории merge-коммитом и оставляет все существующие коммиты как есть. Rebase переигрывает ваши коммиты поверх другой ветки как новые коммиты с новыми хешами, и история получается прямой, поэтому rebase коммитов, которые уже есть у других, ломает их копии.
Fast-forward: если main не сдвинулся с момента ответвления, merge просто передвигает метку main на ваш последний коммит; --no-ff создаёт merge-коммит принудительно, чтобы фича осталась видна как единое целое.
Merge сохраняет настоящую историю и безопасен для общих веток; цена в графе, полном merge-коммитов.
Rebase даёт линейный лог, который проще читать и по которому проще делать bisect; конфликты приходят по одному коммиту.
Золотое правило: делайте rebase только тех коммитов, на которых никто больше не строит работу. Если сделать rebase и force-push ветки, которую коллега уже спулил, его старые коммиты и ваши новые станут двумя копиями одной работы.
Откройте каждый файл с конфликтом, поймите, что хотела каждая сторона, напишите версию, которая сохраняет оба намерения, и удалите маркеры. Затем соберите проект, прогоните тесты, выполните git add и завершите git commit или git rebase --continue; если запутались, git merge --abort вернёт всё как было.
Смотрите на обе стороны и на базу: git status помечает файлы с конфликтом как UU, а git config --global merge.conflictStyle zdiff3 добавляет между сторонами исходный текст, и видно, что изменила каждая сторона.
Разрешайте по смыслу: если одна сторона переименовала метод, а другая добавила его вызов, в результате нужно и то, и другое.
Проверьте до коммита: запустите npx tsc --noEmit и тесты, потому что код может компилироваться и всё равно быть смержен неправильно; git diff --check сообщает о каждом leftover conflict marker.
Никогда не коммитьте маркеры, не оставляйте оба блока вслепую (две копии одного метода или импорта), не берите целиком одну сторону через --ours или --theirs, лишь бы конфликт исчез, и не мержите руками package-lock.json: его нужно сгенерировать заново.
git stash откладывает незакоммиченные изменения и оставляет чистую рабочую копию; git stash pop возвращает их обратно. git cherry-pick <sha> копирует изменение одного коммита в текущую ветку новым коммитом с новым хешем.
git stash
Новые файлы остаются на месте: обычный git stash сохраняет только отслеживаемые изменения; git stash -u захватывает и неотслеживаемые файлы.
Локальны и легко забываются: стэши никогда не пушатся; давайте им имена (git stash push -m "wip: cart badge") и заглядывайте в git stash list.
**pop может дать конфликт**: тогда Git оставляет запись, и после разрешения её удаляют через git stash drop; для долгого отвлечения надёжнее WIP-коммит.
git cherry-pick
Типичный сценарий: перенести фикс из main в release/1.4; -x записывает в сообщение «(cherry picked from commit …)».
Одно изменение дважды: у копии другой хеш, поэтому последующий merge видит два коммита и даёт конфликт, если код вокруг них изменился.
.gitignore лишь не даёт добавить неотслеживаемые файлы; файл, который Git уже отслеживает, остаётся отслеживаемым, что бы ни говорили шаблоны. Уберите его из индекса через git rm --cached и закоммитьте это: файл останется у вас на диске, но уйдёт из репозитория.
Шаблоны отсчитываются от того .gitignore, в котором записаны: /build/ совпадает только на этом уровне, build/ на любой глубине, а ! возвращает путь обратно; git check-ignore -v <path> называет сработавшую строку.
Фронтенд-проект игнорирует node_modules/, результат сборки (dist/, .next/, build/), coverage/, локальные env-файлы (.env.local, .env.*.local), .vercel/ или .netlify/ и логи.
В git остаются lock-файл единственного пакетного менеджера репозитория, .env.example со всеми переменными без настоящих значений, а .env или .env.production, только если в них лишь публичные значения.
**Ловушка после git rm --cached**: коллеги, которые спулят этот коммит, лишатся своей копии файла, и для конфига, заполненного вручную, это болезненно, так что предупредите их заранее.
package.json исправьте руками, оставив зависимости обеих сторон, затем запустите npm install --package-lock-only (или pnpm install): пакетный менеджер прочитает lock-файл с конфликтом, объединит обе стороны и запишет чистый. Если же удалить lock-файл, всё дерево переедет на сегодняшние самые свежие версии.
Lock-файл никогда не правят руками: npm (начиная с 5.7), pnpm и Yarn распознают маркеры конфликта в своём lock-файле и пересобирают его; --package-lock-only делает это, не трогая node_modules.
Почему нельзя удалить и переустановить: каждый диапазон ^ в дереве, включая транзитивные, прыгнет на самый свежий подходящий релиз, и «мерж-коммит» тихо обновит сотни пакетов, которых никто не ревьюил.
Проверьте результат: git diff main -- package-lock.json должен показывать только пакеты, добавленные вашей веткой; затем npm ci и тесты, ровно так, как их запустит CI.
Обе стороны подняли один пакет: оставьте в package.json более высокий диапазон, если только другая сторона не откатила его намеренно.
Один пакетный менеджер: yarn.lock, появившийся рядом с package-lock.json, значит, что кто-то воспользовался другим инструментом; удалите лишний файл, а не пытайтесь синхронизировать оба.
A, B и C сдвигают main обратно на b2 и отличаются только тем, куда попадает изменение из c3: в индекс, в рабочую копию или никуда. D добавляет новый коммит, отменяющий c3, и ничего не переписывает, поэтому для запушенной ветки безопасен только git revert.
**A, --soft**: лог заканчивается на b2, а git status показывает modified: src/api/client.ts в разделе «Changes to be committed». Удобно, чтобы склеить последние коммиты.
**B, --mixed**: лог заканчивается на b2, а то же изменение лежит в разделе «Changes not staged for commit». Удобно, чтобы разбить коммит.
**C, --hard**: лог заканчивается на b2, рабочая копия чистая; о c3 помнит только git reflog, а незакоммиченные правки, которые стирает --hard, теряются навсегда.
**D, revert**: в логе Revert "Add retry", c3, b2, a1; файлы совпадают с b2, а коллегам достаточно сделать pull.
A, B или C на запушенной ветке: git status пишет «Your branch is behind 'origin/main' by 1 commit», а git push отклоняется как non-fast-forward; форс-пуш выдернет c3 из-под всех, у кого он уже есть.
**git restore работает с файлами, а не с историей**: git restore <file> отбрасывает незакоммиченные правки, --staged убирает файл из индекса, а --source=b2 <file> возвращает старую версию одного файла, не сдвигая ветку.
git rebase -i origin/main показывает список коммитов ветки; если заменить pick на squash, fixup, reword или drop или переставить строки, коммиты перепишутся в историю, которую приятно читать. Переписанной ветке нужен форс-пуш, и --force-with-lease откажет, если кто-то успел запушить в неё.
Команды: reword правит сообщение, edit останавливается, чтобы дополнить или разбить коммит, squash вливает коммит в предыдущий и сохраняет оба сообщения, fixup вливает и отбрасывает сообщение, drop удаляет коммит.
Фиксапы по ходу работы: git commit --fixup=<sha>, а затем --autosquash ставит каждый фиксап под его цель.
**--force перезаписывает вслепую: коммит, который коллега час назад запушил в ветку, исчезает.
--force-with-lease** пушит, только если удалённая ветка там, где её видел ваш последний fetch; иначе ! [rejected] … (stale info).
Слабое место: фоновый fetch из IDE обновляет origin/feature, и проверка всё равно проходит; --force-if-includes (Git 2.30+) дополнительно требует, чтобы удалённая вершина входила в вашу историю.
Только своя ветка: переписывание main или общей ветки нарушает золотое правило с любым флагом.
Reset или rebase не удаляет коммиты, а лишь перестаёт на них указывать. git reflog перечисляет все позиции, в которых побывал HEAD, поэтому нужно найти хеш до аварии и вернуть на него ветку через git reset --hard <sha> или git branch rescue <sha>.
Читайте сверху вниз: HEAD@{0} означает текущее состояние; нужное состояние записано в строке сразу под reset: moving to HEAD~2 или сразу под строкой rebase (start).
У каждой ветки свой reflog: git reflog show feature/search, где feature/search@{1} означает предыдущую вершину ветки.
Сразу после rebase или reset всё отменяет git reset --hard ORIG_HEAD.
Не уверены? git branch rescue <sha> закрепит коммит под именем, прежде чем вы начнёте осматриваться.
Что вернуть нельзя: правки, которые ни разу не коммитились и не попадали в стэш, потому что reset --hard их перезаписывает; файлы из индекса живут только как висячие объекты, которые иногда находит git fsck --lost-found.
Локален и устаревает: reflog нет ни на сервере, ни в свежем клоне; по умолчанию недостижимые записи истекают через 30 дней (остальные через 90), и после этого git gc может удалить сами коммиты.
Вы помечаете один коммит плохим, а более старый хорошим, и Git ищет делением пополам среди коммитов между ними: извлекает средний, вы проверяете его и отвечаете good или bad, и диапазон сокращается вдвое, пока не останется один коммит. Тысяча коммитов укладывается примерно в десять шагов.
Начало и конец: git bisect start, git bisect bad, git bisect good v1.4.0; git bisect reset возвращает туда, откуда вы начали.
**git bisect run <cmd> отвечает за вас: код выхода 0 означает good, от 1 до 127 означает bad, а 125 значит «этот коммит проверить нельзя, пропусти».
Сначала напишите проверку: тест, который падает на этом баге, и оставьте его неотслеживаемым, потому что checkout не трогает неотслеживаемые файлы.
Пропускайте то, что не собирается, кодом 125, иначе bisect обвинит коммит, который просто не скомпилировался.
Старые коммиты, старые зависимости**: у каждого коммита свой lock-файл, поэтому скрипт запускает npm ci перед тестом, а коммит, на котором установка падает, возвращает 125.
**git bisect start --first-parent** идёт только по собственным коммитам main и называет влитый pull request, который всё сломал.
Нестабильные тесты всё портят: один неверный ответ уводит поиск не в ту половину.
Хорошее сообщение коротким заголовком в повелительном наклонении говорит, что делает коммит, а в теле объясняет зачем. Conventional Commits добавляют машиночитаемый префикс (feat:, fix:, feat!:), чтобы инструмент сам выбрал следующую семантическую версию и собрал changelog прямо из истории.
Заголовок: около 50 символов, повелительное наклонение («Add retry to upload», а не «Added»), без точки в конце. После пустой строки тело с переносом на 72 символах, где объяснена причина; что изменилось, и так видно в диффе.
Одно логическое изменение на коммит, чтобы его можно было откатить или перенести через cherry-pick отдельно.
Формат: type(scope): description, с типами вроде feat, fix, refactor, perf, test, docs, ci и chore.
Связь с semver: fix поднимает patch, feat поднимает minor, а ! после типа или футер BREAKING CHANGE: поднимает major.
Инструменты: semantic-release или release-please версионируют и публикуют пакет по истории; в монорепозиториях вместо них часто берут Changesets, где каждый pull request добавляет маленький файл с описанием повышения версии. Веб-приложению без версий сгенерированный changelog всё равно полезен для QA и поддержки.
При squash-мерже коммитом в main становится заголовок pull request, поэтому проверяйте заголовки PR в CI; хук commit-msg с commitlint ловит остальное локально.
Хороший pull request делает одну вещь, читается за один присест и объясняет, зачем он нужен и как проверен. Хорошее ревью сначала проверяет поведение и дизайн, оставляет форматирование инструментам и помечает, насколько важен каждый комментарий.
Как автор
Маленький и об одном: несколько сотен изменённых строк; рефакторинг, переименование и фича идут отдельными pull request, чтобы каждый можно было отревьюить и откатить отдельно.
Описание: что и зачем, как проверено, в чём вы не уверены, скриншоты и ссылка на превью-деплой для изменений UI и ссылка на задачу.
Сначала просмотрите свой дифф сами: забытые логи и закомментированный код ловить вам.
Как ревьюер
В таком порядке: делает ли код то, что просит задача, что ломается (граничные случаи, ошибки, конкурентность), вписывается ли он в архитектуру, упадёт ли тест без этого изменения; имена и стиль идут последними или остаются линтеру.
Помечайте вес: blocking:, suggestion:, nit:, и одобряйте с комментариями, когда остались только мелочи.
Спрашивайте, а не приказывайте: «Что будет здесь, если запрос упадёт по таймауту?» учит больше, чем переписанный кусок кода.
Когда вы не согласны
Отвечайте на каждый комментарий: исправлено или причина; после двух кругов поговорите пять минут или позовите техлида, а решение запишите в треде.
GitFlow держит долгоживущие develop и main, а также ветки release и hotfix, и рассчитан на плановые релизы с версиями. GitHub flow означает короткие ветки от main, которые вливаются через PR и выкатываются. Trunk-based идёт дальше: ветки живут день-два, а недоделанное прячется за флагами.
GitFlow: feature/* от develop, release/* замораживается для тестирования, hotfix/* от main и вливается обратно в обе ветки. Подходит для нескольких крупных релизов в год, в остальных случаях тяжёл, а develop уходит от того, что стоит у пользователей.
GitHub flow: каждое изменение идёт через pull request с ревью в main и выпускается оттуда же; нужен CI, достаточно хороший, чтобы main всегда можно было выпустить.
Trunk-based: то же, но ветки живут от нескольких часов до пары дней, а всё неготовое закрыто фича-флагами; мержей больше всего, конфликты мельче всего.
Для веб-приложения: каждый мерж в main деплоится, каждый pull request получает превью-деплой, а откат означает повторный деплой предыдущей сборки, поэтому релизные ветки GitFlow почти ничего не дают. Исключение составляет опубликованная библиотека компонентов, которая может их сохранить.
Как выбирать: по частоте релизов и цене сломанного релиза; большинство команд приходит к trunk-based плюс короткоживущие релизные ветки.
Pre-commit хук должен запускать быстрые самоисправляющие проверки только по застейдженным файлам, например форматтер и быстрый линт. Настоящей проверкой остаётся CI, потому что хуки не клонируются вместе с репозиторием, а git commit --no-verify пропускает их намеренно.
Почему хук ничего не гарантирует: .git/hooks/ локален и никогда не пушится, так что в свежем клоне хуков нет, пока их кто-нибудь не установит; проверка, которая обязана выполняться, живёт в CI и в защите веток.
Общая настройка: хуки лежат в закоммиченной папке .husky/, а скрипт prepare (husky) устанавливает их при каждом npm install.
Что туда идёт: ESLint с --fix и Prettier по застейдженным файлам через lint-staged; секунды, а не минуты, иначе люди начнут тянуться к --no-verify.
Добавляйте в индекс то, что исправил хук: если форматтер переписал файлы после git add, в коммит уйдёт неотформатированная версия; lint-staged сам добавляет обратно файлы, которые изменили его задачи.
Что остаётся за бортом: tsc --noEmit, которому нужна вся программа, а не отдельные файлы, и тесты запускают CI или хук pre-push.
Другие хуки: commit-msg может проверять формат Conventional Commits, а pre-push запускать быстрые юнит-тесты.
Каждый pull request получает собственную развёрнутую копию приложения по уникальному URL, собранную из его ветки, и ревьюеры, QA и дизайнеры прокликивают изменение, а не представляют его по диффу. Безопасными они остаются, только если превью никогда не видят продакшен-секретов и продакшен-данных.
Откуда они берутся: Vercel, Netlify и Cloudflare Pages собирают превью на каждый пуш и пишут URL в pull request; в своей инфраструктуре то же делают CI-джоб и поддомен на каждый PR.
Своё окружение: переменные превью, staging API и тестовые платёжные ключи; до утечки продакшен-ключа из превью-сборки остаётся один вредоносный pull request.
Pull request из форков: GitHub не даёт секретов запускам pull_request из форков, и так должно оставаться; pull_request_target запускается с секретами, поэтому он никогда не должен собирать код форка.
Подальше от поисковиков: Vercel по умолчанию отдаёт для превью X-Robots-Tag: noindex, но перестаёт, если привязать к ветке свой домен; на других хостингах заголовок ставят сами.
Доступ и уборка: защита паролем или SSO, пока продукт не публичный, и превью, которые истекают после закрытия PR.
Часть проверки: прогоняйте end-to-end тесты (Playwright) и Lighthouse по URL превью до мержа; изменению бэкенда всё равно нужен свой бэкенд на каждый PR или флаг.
Сначала отзовите секрет и выпустите новый, потому что он уже в истории, во всех клонах и форках, а новый коммит прячет его только из последней версии. Затем, если старое значение тоже должно исчезнуть, перепишите историю через git filter-repo и сделайте форс-пуш.
Первый час
Сначала замена, потом расследование: отзовите серверный API-ключ у провайдера, выкатите замену и проверьте по логам доступа, не пользовался ли им кто-то после пуша.
Почему нового коммита мало: все старые коммиты по-прежнему содержат значение, боты сканируют публичные пуши в течение минут, а в приватном репозитории копия есть у каждого человека, CI-раннера и интеграции с правом чтения.
Чистка истории
Переписывание: в свежем клоне git filter-repo --sensitive-data-removal --invert-paths --path .env.production убирает файл из всех коммитов (--replace-text вместо этого заменяет строку), а git push --force --mirror origin перезаписывает сервер; защиту веток на это время придётся ослабить.
Чего это стоит: меняются хеши всех последующих коммитов, ломаются открытые pull request, а коллегам нужно клонировать заново, потому что один пуш из старого клона вернёт секрет обратно.
На GitHub ссылки pull request и кешированные страницы хранят старые коммиты, пока их не удалит поддержка GitHub, а у форков остаются свои копии.
В следующий раз
Серверные секреты живут в переменных окружения хостинга или в менеджере секретов, а в переменные VITE_ и NEXT_PUBLIC_, которые попадают в бандл, кладут только публичные значения; в репозитории лежит файл-пример с именами без значений.
Сканер секретов (gitleaks, trufflehog) в pre-commit хуке и в CI плюс push protection на стороне хостинга остановят следующий секрет до того, как он попадёт в репозиторий.
Revert отменяет изменения, но не историю: два коммита PR #42 по-прежнему в main, поэтому второй merge приносит только то, что появилось в ветке после, то есть один фикс. Чтобы вернуть фичу, откатите сам revert (git revert <revert-sha>), а затем влейте фикс.
Что видит Git: база слияния main и ветки теперь равна старой вершине ветки, которая уже входит в main. От неё ветка изменила один файл, поэтому merge применяет одно изменение поверх main, из которого revert убрал всё остальное.
Вывод выдаёт проблему: Merge made by the 'ort' strategy., а затем src/cart/cart.ts | 1 + для фичи из двух коммитов.
Как исправить: откатить сам ревёрт-коммит (Git назовёт результат Reapply "Merge PR #42: discounts"), а затем влить ветку; на GitHub то же делает кнопка Revert в pull request с ревёртом.
Если фикс трогает код, добавленный фичей, merge остановится с конфликтом (CONFLICT (modify/delete), если файл был новым), и проблема хотя бы будет заметна.
**-m 1**: у merge-коммита два родителя, и -m 1 говорит revert сохранить сторону main; почти всегда нужно именно это.
Revert всё равно правильный инструмент: в общем main он остаётся единственной отменой, которая ничего не переписывает. Ловушка в том, что сама отмена теперь тоже часть истории, поэтому запишите план в pull request с ревёртом.