Коммит хранит снимок всего проекта вместе с родительскими коммитами, автором и сообщением, и идентифицируется хешем от всего этого. Ветка представляет собой подвижное имя, указывающее на один коммит, а 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.
GoJunior
Git и командная работа
Merge и rebase, конфликты, reset и revert, cherry-pick, stash, reflog и bisect, стратегии ветвления, pull request и код-ревью, соглашения о коммитах, секреты в истории, go.mod и go.sum
Закрыто вопросов
0 / 18
- 0Повторить
Вопросы
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 добавляет между сторонами исходный текст, и видно, что изменила каждая сторона.
Разрешайте по смыслу: если одна сторона переименовала метод, а другая добавила его вызов, в результате нужно и то, и другое.
Проверьте до коммита: запустите go build ./... и go test ./..., потому что код может компилироваться и всё равно быть смержен неправильно; git diff --check сообщает о каждом leftover conflict marker.
Никогда не коммитьте маркеры, не оставляйте оба блока вслепую (две копии одного метода или импорта), не берите целиком одну сторону через --ours или --theirs, лишь бы конфликт исчез, и не мержите руками сгенерированный код вроде *.pb.go или вывода sqlc: его нужно сгенерировать заново.
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> называет сработавшую строку.
Go-проект игнорирует собранные бинарники (/bin/, /shop), тестовые бинарники (*.test), профили покрытия (coverage.out), локальные .env, а также go.work с go.work.sum, когда модули выпускаются по отдельности.
В git остаются go.mod и go.sum, результат go generate и vendor/, только если команда вендорит зависимости.
**Ловушка после git rm --cached**: коллеги, которые спулят этот коммит, лишатся своей копии файла, и для конфига, заполненного вручную, это болезненно, так что предупредите их заранее.
Смержите строки require руками, оставив все модули, нужные любой из сторон, и более высокую версию там, где обе изменили один модуль, затем запустите go mod tidy и сборку. Если взять одну сторону и дать tidy добавить остальное, импорты другой стороны получат самые свежие релизы, а не проверенные ею версии.
Почему более высокая версия: строки require задают минимумы, и MVS всё равно соберёт с наибольшей; если оставить меньшую, это либо ничего не изменит, либо понизит версию для кода, который другая сторона проверяла.
Tidy не читает маркеры: он останавливается с malformed module path "<<<<<<<": invalid char '<', поэтому файл должен стать корректным до запуска любой команды go.
Ловушка: оставьте go.mod из main, и tidy найдёт импорт golang.org/x/sync из ветки без требования и выберет последний релиз, который здесь к тому же потребовал более новый Go.
**go.sum представляет собой набор хешей: оставить строки обеих сторон безопасно, а tidy удалит те, что никому не нужны.
Ищите replace**: replace … => ../lib, который кто-то использовал локально, работает только на его машине; для локальной работы есть go.work.
Проверьте до коммита: go build ./..., go test ./... и go mod verify; git diff main -- go.mod должен показывать только ваши модули, а go mod tidy -diff в CI падает на всём, что осталось неприбранным.
A, B и C сдвигают main обратно на b2 и отличаются только тем, куда попадает изменение из c3: в индекс, в рабочую копию или никуда. D добавляет новый коммит, отменяющий c3, и ничего не переписывает, поэтому для запушенной ветки безопасен только git revert.
**A, --soft**: лог заканчивается на b2, а git status показывает modified: client/client.go в разделе «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 обвинит коммит, который просто не скомпилировался.
Старые коммиты, старые зависимости**: команда go скачивает всё, что просит go.mod каждого коммита, так что ничего дополнительно делать не нужно.
**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.
Инструменты: release-please (тип релиза go) или semantic-release пишут changelog и ставят тег, а GoReleaser собирает бинарники из этого тега. Для модуля тег и есть версия, поэтому ломающему feat! после v1 нужен ещё и путь модуля с /v2.
При squash-мерже коммитом в main становится заголовок pull request, поэтому проверяйте заголовки PR в CI; хук commit-msg с commitlint ловит остальное локально.
Хороший pull request делает одну вещь, читается за один присест и объясняет, зачем он нужен и как проверен. Хорошее ревью сначала проверяет поведение и дизайн, оставляет форматирование инструментам и помечает, насколько важен каждый комментарий.
Как автор
Маленький и об одном: несколько сотен изменённых строк; рефакторинг, переименование и фича идут отдельными pull request, чтобы каждый можно было отревьюить и откатить отдельно.
Описание: что и зачем, как проверено, в чём вы не уверены, пример запроса и ответа для изменения API и ссылка на задачу.
Сначала просмотрите свой дифф сами: забытые логи и закомментированный код ловить вам.
Как ревьюер
В таком порядке: делает ли код то, что просит задача, что ломается (граничные случаи, ошибки, конкурентность), вписывается ли он в архитектуру, упадёт ли тест без этого изменения; имена и стиль идут последними или остаются линтеру.
Помечайте вес: 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: то же, но ветки живут от нескольких часов до пары дней, а всё неготовое закрыто фича-флагами; мержей больше всего, конфликты мельче всего.
Для бэкенд-сервиса: GitHub flow или trunk-based подходят под непрерывный деплой, где каждый мерж в main становится деплоем, а неудачный откатывается повторным деплоем предыдущего образа. Релизные ветки нужны только библиотеке, которая поддерживает старую мажорную версию.
Как выбирать: по частоте релизов и цене сломанного релиза; большинство команд приходит к trunk-based плюс короткоживущие релизные ветки.
Pre-commit хук должен запускать быстрые самоисправляющие проверки только по застейдженным файлам, например форматтер и быстрый линт. Настоящей проверкой остаётся CI, потому что хуки не клонируются вместе с репозиторием, а git commit --no-verify пропускает их намеренно.
Почему хук ничего не гарантирует: .git/hooks/ локален и никогда не пушится, так что в свежем клоне хуков нет, пока их кто-нибудь не установит; проверка, которая обязана выполняться, живёт в CI и в защите веток.
Общая настройка: закоммитьте lefthook.yml (или .pre-commit-config.yaml, или папку .githooks/ вместе с git config core.hooksPath .githooks) и устанавливайте его из скрипта настройки проекта.
Что туда идёт: gofmt или goimports по застейдженным файлам и go vet; секунды, а не минуты, иначе люди начнут тянуться к --no-verify.
Добавляйте в индекс то, что исправил хук: если форматтер переписал файлы после git add, в коммит уйдёт неотформатированная версия; в lefthook их добавляет обратно stage_fixed: true.
Что остаётся за бортом: golangci-lint по всему модулю, тесты с -race и проверку, что в go.mod не попал replace на локальный каталог, берёт на себя CI.
Другие хуки: commit-msg может проверять формат Conventional Commits, а pre-push запускать быстрые юнит-тесты.
Сначала отзовите секрет и выпустите новый, потому что он уже в истории, во всех клонах и форках, а новый коммит прячет его только из последней версии. Затем, если старое значение тоже должно исчезнуть, перепишите историю через git filter-repo и сделайте форс-пуш.
Первый час
Сначала замена, потом расследование: отзовите пароль от базы данных, выкатите замену и проверьте по логам доступа, не пользовался ли им кто-то после пуша.
Почему нового коммита мало: все старые коммиты по-прежнему содержат значение, боты сканируют публичные пуши в течение минут, а в приватном репозитории копия есть у каждого человека, CI-раннера и интеграции с правом чтения.
Чистка истории
Переписывание: в свежем клоне git filter-repo --sensitive-data-removal --invert-paths --path .env убирает файл из всех коммитов (--replace-text вместо этого заменяет строку), а git push --force --mirror origin перезаписывает сервер; защиту веток на это время придётся ослабить.
Чего это стоит: меняются хеши всех последующих коммитов, ломаются открытые pull request, а коллегам нужно клонировать заново, потому что один пуш из старого клона вернёт секрет обратно.
На GitHub ссылки pull request и кешированные страницы хранят старые коммиты, пока их не удалит поддержка GitHub, а у форков остаются свои копии.
В следующий раз
Секреты попадают в процесс через окружение или смонтированные файлы из менеджера секретов (Vault, AWS Secrets Manager, Kubernetes Secrets); в репозитории лежит файл-пример с именами без значений.
Сканер секретов (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., а затем cart/cart.go | 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 с ревёртом.
Semver-тег вроде v1.4.0 на коммите и есть релиз: go get example.com/lib@v1.4.0 разрешает его, а прокси модулей и база контрольных сумм навсегда запоминают содержимое версии. Перенос тега меняет код, который все уже проверили, и пользователи получают либо старый код, либо ошибку контрольной суммы.
Формат тега: vMAJOR.MINOR.PATCH с буквой v; модуль в подкаталоге получает тег с префиксом (api/v1.4.0); начиная с v2 сам путь модуля заканчивается на /v2.
Коммиты без тега: go get example.com/lib@main записывает псевдоверсию вроде v1.4.1-0.20261024101530-3f9c2e1a7b4d; это годится, чтобы попробовать фикс, но не для релиза.
Неизменяемость после первой загрузки: proxy.golang.org хранит то содержимое версии, которое увидел первым, sum.golang.org записывает его хеш, и он же лежит в go.sum каждого потребителя.
Перенос тега: прокси продолжает отдавать старый код, так что через него ваш фикс не доходит ни до кого, а те, кто качает напрямую, получают checksum mismatch и SECURITY ERROR.
Удаление тега ничего не отменяет: прокси по-прежнему отдаёт версию.
Исправление только новой версией: поставьте тег v1.4.1 с фиксом и добавьте в его go.mod строку retract v1.4.0 с причиной; go get будет пропускать плохую версию, а go list -m -u пометит её как отозванную.
Защитите релизные теги: правило для тегов в репозитории, чтобы никто не мог форс-пушить или удалять v*.