Коммит хранит снимок всего проекта вместе с родительскими коммитами, автором и сообщением, и идентифицируется хешем от всего этого. Ветка представляет собой подвижное имя, указывающее на один коммит, а 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.
FlutterJunior
Git и командная работа
Merge и rebase, конфликты, reset и revert, cherry-pick, stash, reflog и bisect, стратегии ветвления, pull request и код-ревью, соглашения о коммитах, секреты в истории, pubspec.lock и сгенерированные файлы
Закрыто вопросов
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 добавляет между сторонами исходный текст, и видно, что изменила каждая сторона.
Разрешайте по смыслу: если одна сторона переименовала метод, а другая добавила его вызов, в результате нужно и то, и другое.
Проверьте до коммита: запустите flutter analyze и flutter test, потому что код может компилироваться и всё равно быть смержен неправильно; git diff --check сообщает о каждом leftover conflict marker.
Никогда не коммитьте маркеры, не оставляйте оба блока вслепую (две копии одного метода или импорта), не берите целиком одну сторону через --ours или --theirs, лишь бы конфликт исчез, и не мержите руками pubspec.lock или сгенерированный .g.dart: его нужно сгенерировать заново.
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> называет сработавшую строку.
Flutter-проект игнорирует .dart_tool/, build/, ios/Pods/, android/local.properties, android/key.properties, кейсторы (*.jks) и .fvm/; шаблон flutter create перечисляет большую часть из них.
В git остаются pubspec.lock (для приложения), ios/Podfile.lock, .metadata, .fvmrc и properties-файл Gradle wrapper, а сгенерированные .g.dart только если команда решила их коммитить.
**Ловушка после git rm --cached**: коллеги, которые спулят этот коммит, лишатся своей копии файла, и для конфига, заполненного вручную, это болезненно, так что предупредите их заранее.
Закрепите точную версию в файле внутри репозитория: в .fvmrc от FVM, который пишет fvm use 3.38.0, или точной версией flutter: в разделе environment файла pubspec.yaml. Ноутбуки и CI читают один и тот же файл, а сам SDK в git не попадает никогда.
**Коммитьте .fvmrc**, где записана версия; после клонирования fvm install скачивает этот SDK.
**Игнорируйте .fvm/**: там лежит симлинк flutter_sdk в кеш этой машины, и FVM сам добавляет папку в .gitignore.
CI читает тот же файл: subosito/flutter-action принимает flutter-version-file: .fvmrc или pubspec.yaml с точной версией; версия, вписанная прямо в workflow, расходится с ноутбуками.
Закрепление в pubspec проверяет себя само: с flutter: 3.37.0 и установленным SDK 3.38.0 pub get останавливается с «Because shop requires Flutter SDK version 3.37.0, version solving failed.»
pubspec.yaml разрешите руками, оставив зависимости обеих сторон, затем возьмите pubspec.lock из main и запустите flutter pub get: pub сохранит все версии, зафиксированные в main, и решит только ваши добавления. Перед коммитом проверьте дифф lock-файла относительно main.
Почему не мержить lock руками: это результат работы солвера с хешами содержимого, и смесь, собранная вручную, может описывать комбинацию, которую pub никогда не решал.
Где какая сторона: во время git merge main --theirs означает main; во время git rebase main стороны меняются местами, и та же команда git checkout --theirs pubspec.lock возьмёт ваш старый lock.
Обе стороны подняли один пакет: оставьте более высокое ограничение, если только одна из сторон не понизила его намеренно.
Проверьте, что сдвинулось: git diff main -- pubspec.lock должен показывать только ваши пакеты и их транзитивные зависимости.
**ios/Podfile.lock тоже**: возьмите версию из main, после pub get запустите pod install и закоммитьте результат.
CI как страховка: flutter pub get --enforce-lockfile падает, если закоммиченный lock не соответствует pubspec.yaml.
A, B и C сдвигают main обратно на b2 и отличаются только тем, куда попадает изменение из c3: в индекс, в рабочую копию или никуда. D добавляет новый коммит, отменяющий c3, и ничего не переписывает, поэтому для запушенной ветки безопасен только git revert.
**A, --soft**: лог заканчивается на b2, а git status показывает modified: lib/data/api_client.dart в разделе «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 обвинит коммит, который просто не скомпилировался.
Старые коммиты, старые зависимости**: flutter test сам запускает pub get, если pubspec.yaml изменился, но коммит, зависимости которого больше не разрешаются, должен вернуть 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.
Инструменты: release-please (тип релиза dart) держит открытым релизный pull request, который обновляет CHANGELOG.md и version: в pubspec.yaml, а его мерж ставит тег релиза; версию приложения всё равно решает продукт, но заметки идут в текст обновления в сторе.
При squash-мерже коммитом в main становится заголовок pull request, поэтому проверяйте заголовки PR в CI; хук commit-msg с commitlint ловит остальное локально.
Делайте PR маленьким и об одном, объясняйте, зачем он нужен и как проверен, и просматривайте свой дифф раньше всех остальных. Если не согласны, отвечайте доводами, а не молчанием или молчаливым согласием; если два круга ничего не решили, обсудите вживую и запишите решение.
Дёшево отревьюить: несколько сотен изменённых строк, рефакторинг отдельно от фичи, которой он нужен, описание с тем, что и зачем сделано и как проверено (устройство, платформа, флейвор), скриншоты изменений UI на iOS и Android и просмотр собственного диффа заранее: забытый print или посторонняя правка pubspec.yaml ловить вам.
Длинная работа цепочкой: несколько маленьких pull request, каждый на основе предыдущего, лучше одного PR на 2000 строк; недоделанные части остаются за флагом.
На каждый комментарий есть ответ: «сделано» или причина. Если менять код, который вы считаете неверным, только ради одобрения, разногласие спрячется, пока не станет багом.
Спорьте фактами: отделяйте вкус от корректности, приводите бенчмарк, документацию или падающий пример и предлагайте отдельную задачу для справедливого замечания за рамками этого PR.
Тупик: после двух кругов короткий созвон или техлид, а итог записывается в треде.
Влейте main в ветку сейчас одним осознанным мержем, разрешите конфликты и прогоните тесты, а результат по возможности отправьте несколькими pull request, которые реально отревьюить. В следующий раз интегрируйтесь каждый день и вливайте фичу в main маленькими кусками за флагом, а не держите её в ветке.
Как выбраться
**Вливайте main, а не делайте rebase: rebase переигрывает каждый из 40 коммитов и может остановиться на любом; один merge разрешает каждый конфликт один раз.
Разрешайте вместе с теми, кто менял другую сторону: десять минут разговора лучше догадок о том, зачем код изменился.
Проверьте результат полностью**: flutter analyze, flutter test и запуск на iOS и на Android; опаснее всего конфликты, которые чисто компилируются.
Разделите до ревью: рефакторинги и общие части сначала отправьте в main отдельными pull request, чтобы остаток стал меньше.
Чтобы не повторилось
Интегрируйтесь каждый день: git fetch и git merge origin/main держат каждый конфликт маленьким и свежим.
Вливайте недоделанное за флагом: const bool.fromEnvironment('NEW_CHECKOUT') на этапе компиляции или значение из Remote Config; код живёт в main, а фича остаётся выключенной.
Branch by abstraction для больших переписываний: интерфейс перед старым кодом, новая реализация рядом, переключение в конце.
Цепочки pull request: несколько маленьких PR, каждый поверх предыдущего, дают ревьюерам по несколько сотен строк за раз.
Сначала отзовите секрет и выпустите новый, потому что он уже в истории, во всех клонах и форках, а новый коммит прячет его только из последней версии. Затем, если старое значение тоже должно исчезнуть, перепишите историю через git filter-repo и сделайте форс-пуш.
Первый час
Сначала замена, потом расследование: отзовите ключ платёжного API в кабинете провайдера, выкатите замену и проверьте по логам доступа, не пользовался ли им кто-то после пуша.
Почему нового коммита мало: все старые коммиты по-прежнему содержат значение, боты сканируют публичные пуши в течение минут, а в приватном репозитории копия есть у каждого человека, CI-раннера и интеграции с правом чтения.
Чистка истории
Переписывание: в свежем клоне git filter-repo --sensitive-data-removal --invert-paths --path config/prod.json убирает файл из всех коммитов (--replace-text вместо этого заменяет строку), а git push --force --mirror origin перезаписывает сервер; защиту веток на это время придётся ослабить.
Чего это стоит: меняются хеши всех последующих коммитов, ломаются открытые pull request, а коллегам нужно клонировать заново, потому что один пуш из старого клона вернёт секрет обратно.
На GitHub ссылки pull request и кешированные страницы хранят старые коммиты, пока их не удалит поддержка GitHub, а у форков остаются свои копии.
В следующий раз
Значения для сборки приходят из секретов CI или из игнорируемого файла, который передаётся в --dart-define-from-file, а настоящие секреты остаются на сервере, потому что всё, что скомпилировано в приложение, из него можно достать; в репозитории лежит файл-пример с именами без значений.
Сканер секретов (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., а затем lib/cart/cart.dart | 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».
Один набор инструментов и правил: одна версия Flutter, один analysis_options.yaml и pub workspace с одним pubspec.lock на все пакеты.
Чего он стоит
CI должен собирать только изменённое, иначе каждый pull request гоняет всё: Melos умеет выбрать пакеты, изменённые с origin/main, вместе со всеми, кто от них зависит.
Владение: CODEOWNERS по папкам, чтобы код платежей ревьюила команда платежей.
Масштаб и доступы: клон постоянно растёт (помогают git clone --filter=blob:none и sparse checkout), а права выдаются на репозиторий целиком, так что подрядчик видит всё.
Независимые релизы усложняются: каждому пакету нужны свои теги (core-v1.4.0) и инструменты публикации.
Чего стоят отдельные репозитории
Каждое общее изменение означает публикацию, новую версию и pull request в каждом потребителе, а потребители расходятся по версиям.
Они выигрывают для open-source пакетов, для кода с другим уровнем доступа и для команд, которые действительно выпускаются по своему графику.