Коммит хранит снимок всего проекта вместе с родительскими коммитами, автором и сообщением, и идентифицируется хешем от всего этого. Ветка представляет собой подвижное имя, указывающее на один коммит, а 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.
AndroidJunior
Git и командная работа
Merge и rebase, конфликты, reset и revert, cherry-pick, stash, reflog и bisect, стратегии ветвления, pull request и код-ревью, соглашения о коммитах, секреты в истории, файлы Gradle и keystore
Закрыто вопросов
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 добавляет между сторонами исходный текст, и видно, что изменила каждая сторона.
Разрешайте по смыслу: если одна сторона переименовала метод, а другая добавила его вызов, в результате нужно и то, и другое.
Проверьте до коммита: запустите ./gradlew assembleDebug testDebugUnitTest, потому что код может компилироваться и всё равно быть смержен неправильно; git diff --check сообщает о каждом leftover conflict marker.
Никогда не коммитьте маркеры, не оставляйте оба блока вслепую (две копии одного метода или импорта), не берите целиком одну сторону через --ours или --theirs, лишь бы конфликт исчез, и не мержите руками gradle.lockfile или сгенерированный исходник: его нужно сгенерировать заново.
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> называет сработавшую строку.
Android-проект игнорирует .gradle/, build/, local.properties (путь к SDK, часто ещё и API-ключи), *.iml и большую часть .idea/, .cxx/, а также релизные кейсторы (*.jks, *.keystore) вместе с keystore.properties, где лежат пароли к ним.
В git остаются gradlew, gradlew.bat и gradle/wrapper/ (jar и properties, иначе сборка не стартует), libs.versions.toml, gradle.properties без секретов и обычно google-services.json, который указывает на проект Firebase, но секретом не является.
**Ловушка после git rm --cached**: коллеги, которые спулят этот коммит, лишатся своей копии файла, и для конфига, заполненного вручную, это болезненно, так что предупредите их заранее.
Разрешайте по смыслу, строка за строкой: оставьте новые записи обеих сторон, а там, где обе изменили одну версию, берите более новую, если только другая сторона не откатила её намеренно. Затем соберите проект, а lock-файлы и метаданные верификации, если сборка их использует, сгенерируйте заново, а не мержите руками.
Здесь ручной мерж как раз правильный: в отличие от lock-файла, каждая строка каталога отражает чьё-то решение, и сообщения коммитов объясняют, почему версия сдвинулась.
Никогда не берите одну сторону для всего блока: main поднял AGP и Kotlin, ваша ветка добавила Room, и в результате нужны все три изменения.
Версии ходят группами: Kotlin вместе с KSP и плагином компилятора Compose, BOM вместе с библиотеками, которые берут версию из него; проверьте, что итоговый набор совместим.
Меньше конфликтов в будущем: держите каждый блок отсортированным, по одной записи на строку, тогда два добавления редко задевают одни и те же строки.
Генерируйте, а не мержите: при dependency locking перезапустите задачу блокировки (./gradlew dependencies --write-locks или собственную задачу проекта для всех модулей); при verification metadata выполните ./gradlew --write-verification-metadata sha256 help.
Затем докажите, что всё работает: ./gradlew assembleDebug testDebugUnitTest, а если транзитивная версия неожиданно прыгнула, посмотрите ./gradlew :app:dependencies.
A, B и C сдвигают main обратно на b2 и отличаются только тем, куда попадает изменение из c3: в индекс, в рабочую копию или никуда. D добавляет новый коммит, отменяющий c3, и ничего не переписывает, поэтому для запушенной ветки безопасен только git revert.
**A, --soft**: лог заканчивается на b2, а git status показывает modified: app/src/main/kotlin/ApiClient.kt в разделе «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 обвинит коммит, который просто не скомпилировался.
Старые коммиты, старые зависимости**: Gradle wrapper и каталог версий приходят вместе с каждым коммитом, поэтому ./gradlew собирает его своими версиями Gradle и зависимостей; ваш только JDK, и коммит, которому нужен другой, пропускается.
**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 или semantic-release превращают историю в changelog и тег. versionName приложения всё равно решает продукт, но сгенерированные заметки становятся текстом обновления в Play Console.
При squash-мерже коммитом в main становится заголовок pull request, поэтому проверяйте заголовки PR в CI; хук commit-msg с commitlint ловит остальное локально.
Хороший pull request делает одну вещь, читается за один присест и объясняет, зачем он нужен и как проверен. Хорошее ревью сначала проверяет поведение и дизайн, оставляет форматирование инструментам и помечает, насколько важен каждый комментарий.
Как автор
Маленький и об одном: несколько сотен изменённых строк; рефакторинг, переименование и фича идут отдельными pull request, чтобы каждый можно было отревьюить и откатить отдельно.
Описание: что и зачем, как проверено, в чём вы не уверены, скриншоты или запись экрана для изменений UI, в светлой и тёмной теме и ссылка на задачу.
Сначала просмотрите свой дифф сами: забытые логи и закомментированный код ловить вам.
Как ревьюер
В таком порядке: делает ли код то, что просит задача, что ломается (граничные случаи, ошибки, конкурентность), вписывается ли он в архитектуру, упадёт ли тест без этого изменения; имена и стиль идут последними или остаются линтеру.
Помечайте вес: blocking:, suggestion:, nit:, и одобряйте с комментариями, когда остались только мелочи.
Спрашивайте, а не приказывайте: «Что будет здесь, если запрос упадёт по таймауту?» учит больше, чем переписанный кусок кода.
Когда вы не согласны
Отвечайте на каждый комментарий: исправлено или причина; после двух кругов поговорите пять минут или позовите техлида, а решение запишите в треде.
Влейте main в ветку сейчас одним осознанным мержем, разрешите конфликты и прогоните тесты, а результат по возможности отправьте несколькими pull request, которые реально отревьюить. В следующий раз интегрируйтесь каждый день и вливайте фичу в main маленькими кусками за флагом, а не держите её в ветке.
Как выбраться
**Вливайте main, а не делайте rebase: rebase переигрывает каждый из 40 коммитов и может остановиться на любом; один merge разрешает каждый конфликт один раз.
Разрешайте вместе с теми, кто менял другую сторону: десять минут разговора лучше догадок о том, зачем код изменился.
Проверьте результат полностью**: ./gradlew lint testDebugUnitTest и инструментальные тесты затронутых фич; опаснее всего конфликты, которые чисто компилируются.
Разделите до ревью: рефакторинги и общие части сначала отправьте в main отдельными pull request, чтобы остаток стал меньше.
Чтобы не повторилось
Интегрируйтесь каждый день: git fetch и git merge origin/main держат каждый конфликт маленьким и свежим.
Вливайте недоделанное за флагом: поле BuildConfig для каждого типа сборки или значение из Remote Config; код живёт в main, а фича остаётся выключенной.
Branch by abstraction для больших переписываний: интерфейс перед старым кодом, новая реализация рядом, переключение в конце.
Цепочки pull request: несколько маленьких PR, каждый поверх предыдущего, дают ревьюерам по несколько сотен строк за раз.
Pre-commit хук должен запускать быстрые самоисправляющие проверки только по застейдженным файлам, например форматтер и быстрый линт. Настоящей проверкой остаётся CI, потому что хуки не клонируются вместе с репозиторием, а git commit --no-verify пропускает их намеренно.
Почему хук ничего не гарантирует: .git/hooks/ локален и никогда не пушится, так что в свежем клоне хуков нет, пока их кто-нибудь не установит; проверка, которая обязана выполняться, живёт в CI и в защите веток.
Общая настройка: закоммитьте lefthook.yml (или .pre-commit-config.yaml, или папку .githooks/ вместе с git config core.hooksPath .githooks) и устанавливайте его из скрипта настройки проекта.
Что туда идёт: ktlint (или Spotless) по застейдженным Kotlin-файлам, а detekt только если он остаётся быстрым; секунды, а не минуты, иначе люди начнут тянуться к --no-verify.
Добавляйте в индекс то, что исправил хук: если форматтер переписал файлы после git add, в коммит уйдёт неотформатированная версия; в lefthook их добавляет обратно stage_fixed: true.
Что остаётся за бортом: Gradle-задачи, которые конфигурируют весь проект, Android Lint и тесты берёт на себя CI.
Другие хуки: commit-msg может проверять формат Conventional Commits, а pre-push запускать быстрые юнит-тесты.
Сначала отзовите секрет и выпустите новый, потому что он уже в истории, во всех клонах и форках, а новый коммит прячет его только из последней версии. Затем, если старое значение тоже должно исчезнуть, перепишите историю через git filter-repo и сделайте форс-пуш.
Первый час
Сначала замена, потом расследование: отзовите ключ сервисного аккаунта Play в Google Cloud, выкатите замену и проверьте по логам доступа, не пользовался ли им кто-то после пуша.
Почему нового коммита мало: все старые коммиты по-прежнему содержат значение, боты сканируют публичные пуши в течение минут, а в приватном репозитории копия есть у каждого человека, CI-раннера и интеграции с правом чтения.
Чистка истории
Переписывание: в свежем клоне git filter-repo --sensitive-data-removal --invert-paths --path app/play-service-account.json убирает файл из всех коммитов (--replace-text вместо этого заменяет строку), а git push --force --mirror origin перезаписывает сервер; защиту веток на это время придётся ослабить.
Чего это стоит: меняются хеши всех последующих коммитов, ломаются открытые pull request, а коллегам нужно клонировать заново, потому что один пуш из старого клона вернёт секрет обратно.
На GitHub ссылки pull request и кешированные страницы хранят старые коммиты, пока их не удалит поддержка GitHub, а у форков остаются свои копии.
В следующий раз
Секреты живут в хранилище секретов CI и в ~/.gradle/gradle.properties или переменных окружения, но никогда в gradle.properties проекта; в репозитории лежит файл-пример с именами без значений.
Сканер секретов (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., а затем app/src/main/kotlin/Cart.kt | 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 с ревёртом.
Монорепозиторий выбирают, когда код меняется вместе: приложения делят дизайн-систему, API-клиент и модели, и один pull request должен обновить библиотеку и всех её пользователей сразу. Отдельные репозитории подходят частям со своими владельцами и циклами релизов ценой версионирования между ними.
Что даёт монорепозиторий
Атомарные изменения: ломающее изменение в общем пакете и его исправление во всех приложениях попадают в один pull request с одним ревью; никакой матрицы «приложение 2 всё ещё на core 1.4».
Один набор инструментов и правил: одна Gradle-сборка, convention-плагины и один каталог версий на все модули.
Чего он стоит
CI должен собирать только изменённое, иначе каждый pull request гоняет всё: build cache Gradle и фильтр изменённых модулей, например Affected Module Detector от Dropbox.
Владение: CODEOWNERS по папкам, чтобы код платежей ревьюила команда платежей.
Масштаб и доступы: клон постоянно растёт (помогают git clone --filter=blob:none и sparse checkout), а права выдаются на репозиторий целиком, так что подрядчик видит всё.
Независимые релизы усложняются: каждому пакету нужны свои теги (core-v1.4.0) и инструменты публикации.
Чего стоят отдельные репозитории
Каждое общее изменение означает публикацию, новую версию и pull request в каждом потребителе, а потребители расходятся по версиям.
Они выигрывают для open-source пакетов, для кода с другим уровнем доступа и для команд, которые действительно выпускаются по своему графику.