Удалённые репозитории и PR
До этого момента весь Git жил у тебя на диске: объекты, ветки, индекс — всё локально. Но Git задумывался как распределённая система: у каждого разработчика — полная копия истории, а синхронизация — явные команды, которые ты контролируешь. Понимание того, что именно летает по сети и что лежит в .git после каждой операции, отличает человека, который «пользуется GitHub», от человека, который понимает Git.
В краткой версии ты делал clone, pull, push по памяти. Здесь разберём под капотом: remote-tracking ветки, разницу fetch и pull на уровне объектов, отказ non-fast-forward, форк-воркфлоу, цикл code review и то, как виды мержа меняют историю. Это глава, после которой git push --force перестаёт быть магией и становится предсказуемой механикой.
Что скачивает clone
Заголовок раздела «Что скачивает clone»git clone (см. git-clone(1)) — это не «скачать файлы». Это полная копия репозитория: все объекты (блобы, деревья, коммиты, теги), все ветки, вся история с самого первого коммита.
git clone git@github.com:octo/example.gitcd exampleЧто появляется на диске:
СЕРВЕР (origin) ТВОЙ ДИСК. example/├── objects/ ← все коммиты, └── .git/│ деревья, блобы ├── objects/ ← ПОЛНАЯ КОПИЯ├── refs/heads/ ← ветки ├── refs/heads/ ← твои ветки│ main, feature-x │ main└── refs/tags/ ├── refs/remotes/ ← ОСОБАЯ ЗОНА │ origin/main ← remote-tracking! │ origin/feature-x └── refs/tags/ ← все тегиДва важных следствия:
- После clone можно работать офлайн. Все коммиты за последние годы лежат у тебя.
git log,git diff, дажеgit rebase— всё работает без сети. origin/mainиmain— разные ветки.main— твоя локальная ветка, которую ты можешь двигать.origin/main— это снимок того, где была веткаmainна сервере в момент последней синхронизации. Git хранит его в отдельном namespacerefs/remotes/, и ты не можешь «закоммитить в origin/main» напрямую — только обновить черезfetch/push.
refs/heads/main → твоя ветка (куда указывает твой HEAD при checkout main)refs/remotes/origin/main → «зеркало» серверной ветки (обновляется только синхронизацией)Кстати, почему репозиторий называется origin? Просто дефолтное имя: Git присваивает удалённому репозиторию, из которого клонировали, имя origin. Это просто ярлык на URL:
git remote -v# origin git@github.com:octo/example.git (fetch)# origin git@github.com:octo/example.git (push)В конфиге это обычная запись в .git/config:
git config --get remote.origin.urlНесколько remote
Заголовок раздела «Несколько remote»Один репозиторий может знать о многих удалённых. Классика — добавить «апстрим», когда твой форк отстал от оригинала:
# ты склонировал свой форкgit clone git@github.com:you/project.gitcd project
# добавляем оригинальный репозиторий под именем upstreamgit remote add upstream git@github.com:octo/project.gitgit fetch upstream
# теперь доступны чужие веткиgit log upstream/main --oneline -5 ┌─────────────────────────────┐ │ github.com/octo/project │ ← upstream (оригинал) │ main: C1─C2─C3─C4 │ └──────────────┬──────────────┘ │ fetch ┌──────────────────────┼──────────────────────┐ ▼ ▼ ▼ refs/remotes/ refs/remotes/ твои ветки upstream/main ≠ origin/main ≠ main (оригинал) (твой форк) (твоя работа)Имена — произвольные ярлыки. Можно назвать upstream, fork, backup. Git не делает различий, различаешь ты.
Один тонкий момент: fetch- и push-URL у remote могут различаться. Классика — читать по HTTPS (работает за строгим прокси), пушить по SSH:
git remote set-url origin https://github.com/octo/project.git # fetchgit remote set-url --push origin git@github.com:octo/project.git # pushgit remote -v # (push) и (fetch) теперь разныеЕщё полезный трюк — push в несколько remote одной командой: git remote set-url --add --push origin git@gitlab.com:you/project.git теперь git push отправляет и на GitHub, и на GitLab (зеркалирование для надёжности).
fetch vs pull: ключевая разница
Заголовок раздела «fetch vs pull: ключевая разница»Это вопрос, который спрашивают на каждом втором собеседовании. Разберём на уровне объектов.
git fetch (см. git-fetch(1)) скачивает из remote всё новое (объекты, ветки, теги) и обновляет только remote-tracking ветки. Твои локальные ветки, рабочая директория и индекс не трогаются:
git fetch origin# теперь origin/main показывает актуальное состояние сервера,# а твоя main — где была до fetchДО fetch: ПОСЛЕ fetch:
origin/main: C1─C2─C3 origin/main: C1─C2─C3─C4─C5 ← обновиласьmain: C1─C2─C3 main: C1─C2─C3 ← НЕ тронутарабочая папка: совпадает с main рабочая папка: НЕ измениласьgit pull (см. git-pull(1)) = git fetch + слияние (или ребейз) изменений из апстрим-ветки в твою текущую ветку:
git pull origin main# эквивалентно:git fetch origingit merge origin/main # либо git rebase origin/main при pull --rebasegit pull (= fetch + merge):
до: после:main: C1─C2─C3 main: C1─C2─C3─────┐origin/main: C1─C2─C3─C4─C5 origin/main: C1─C2─C3─C4─C5 \__________/ merge-коммит MПолезные диагностические команды после fetch:
# что нового появилось на сервере (коммиты, которых нет у тебя)git log main..origin/main --oneline
# что есть у тебя, но ещё не на сервереgit log origin/main..main --oneline
# какие ветки на сервере удалены (при --prune)git fetch --prune originЗапомни синтаксис ветка..ветка: он работает везде в Git, где есть диапазоны коммитов, и читается как «коммиты, до которых можно дойти из правой ветки, но не из левой». Двойные точки — «только справа», тройные (A...B) — «симметричная разница» (есть в любой, но не в обеих).
push и отказ non-fast-forward
Заголовок раздела «push и отказ non-fast-forward»git push (см. git-push(1)) — зеркальная операция fetch: отправляет объекты и просит сервер передвинуть ветку. Но сервер защищён от потери истории.
Fast-forward — это когда твоя ветка строго продолжает серверную: сервер просто двигает указатель вперёд, ничего не теряя.
ТЫ push-ишь: всё ОК (fast-forward)
origin/main: C1─C2─C3твоя main: C1─C2─C3─C4─C5 ← новые коммиты ДОБАВЛЯЮТСЯ к истории
после push: C1─C2─C3─C4─C5 ← сервер просто передвинул указательNon-fast-forward — серверная ветка ушла вперёд (кто-то запушил), а твоя история разошлась:
ТЫ push-ишь: ОТКАЗ non-fast-forward
origin/main: C1─C2─C3───C6───C7 ← кто-то успел запушитьтвоя main: C1─C2─C3─C4─C5 ← твои коммиты из ДРУГОЙ ветки истории \ \ \______ разошлись — перезапись потеряла бы C6, C7
Git отказывает: push был бы ПЕРЕЗАПИСЬЮ, а сервер терять чужие коммиты не будет.Правильный порядок действий:
git pull --rebase origin main # либо merge — твои коммиты становятся поверх C7git push origin main # теперь это fast-forwardпосле pull --rebase: после push:
C1─C2─C3─C6─C7 C1─C2─C3─C6─C7─C4'─C5' \ ↑ origin/main и main совпали C4─C5 → ребейз на C7 → C4'─C5'--force (точнее --force-with-lease) нужен только когда ты сознательно переписываешь опубликованную историю — например, после интерактивного ребейза своей ветки в PR. Подробнее — в главе про merge и rebase.
Upstream и tracking-ветки
Заголовок раздела «Upstream и tracking-ветки»Когда ты создаёшь ветку и пушишь её первый раз, Git связывает локальную и удалённую ветку:
git switch -c feature-logingit push -u origin feature-login# -u (--set-upstream): установить отслеживаниеТеперь feature-login отслеживает origin/feature-login, и Git знает «против чего» сравнивать:
git status# On branch feature-login# Your branch is ahead of 'origin/feature-login' by 2 commits.# (use "git push" to publish your local commits)
git pull # без аргументов — знает, откуда тянутьgit push # без аргументов — знает, куда пушитьЧто хранится под капотом — в конфиге ветки:
git config --get branch.feature-login.remote # origingit config --get branch.feature-login.merge # refs/heads/feature-logintracking-связка:
локальная ветка удалённая веткаfeature-login ←→ origin/feature-login │ │ └── push публикует ──────┘ └── pull забирает отсюда ┘Апстрим-ветка имеет короткий синтаксис @{u} («upstream текущей ветки»), который пригождается в алиасах и скриптах:
git log @{u}.. # что ты уже сделал, но ещё не запушилgit diff @{u} # дифф против апстримаgit rev-parse @{u} # хэш апстрим-коммитаФорк-воркфлоу
Заголовок раздела «Форк-воркфлоу»В опенсорсе и многих компаниях ты не имеешь права пушить в основной репозиторий напрямую. Схема такая:
1. FORK 2. CLONE 3. BRANCH 4. PUSH 5. PULL REQUEST свою копию фичу из main в СВОЙ форк в оригинал
octo/project ──► you/project ──► you/project ──► you/project ──► octo/project(оригинал) (форк на (ветка (ветка (PR: предложение GitHub) feature-login) на форке) слить feature-login в octo/project:main)# полный цикл рукамиgit clone git@github.com:you/project.gitcd projectgit remote add upstream git@github.com:octo/project.git
git switch -c fix-typo-readme # ветка от актуальной main# ... правки ...git commit -am "docs: fix typo"git push -u origin fix-typo-readme # в СВОЙ форк
# затем в браузере: Compare & Pull Request → описание → Create PRПеред созданием PR полезно подтянуть свежий апстрим:
git fetch upstreamgit rebase upstream/main # твои коммиты — поверх свежей maingit push --force-with-lease origin fix-typo-readmeЦикл code review и виды мержа
Заголовок раздела «Цикл code review и виды мержа»PR (подробно — документация GitHub о пул-реквестах) — не кнопка «слить», а процесс. Классический цикл:
┌──────────┐ комментарии ┌──────────┐│ ревьюер │ ───────────────►│ разраб ││ │ │ │└────┬─────┘ └────┬─────┘ │ approve │ правки + git push (PR обновляется) │ │ ▼ │┌─────────────────────────────────┴────┐│ maintainer нажимает Merge │└──────────────────────────────────────┘Каждый новый push в ветку PR автоматически подтягивается в интерфейс, старые комментарии помечаются «outdated», если код изменился. Поэтому правки делаются новыми коммитами или amend + force-push (в ветке PR переписывать историю допустимо — она твоя), но не новым PR.
Качество ревью напрямую зависит от размера диффа: PR на 50 строк ревьюер разбирает за пять минут и ловит реальные баги, PR на 2000 строк получает «LGTM» через страдание. Поэтому большую задачу режут на серию мелких PR за фича-флагами — каждый самодостаточен, тестируем и ревьюится за один заход.
При нажатии Merge у тебя обычно три варианта, и они дают разную историю:
| Вариант | История | Когда использовать |
|---|---|---|
| Merge commit | вся ветка целиком + merge-коммит | сохранить контекст ветки, дефолт в классическом Git |
| Squash and merge | все коммиты ветки схлопываются в один | «WIP», «fix», «oops» не должны попадать в main; PR = одна атомарная правка |
| Rebase and merge | коммиты ветки переписываются поверх main, без merge-коммита | линейная история, но сохраняем гранулярность коммитов |
ИСХОДНАЯ ВЕТКА В PR (main = C1─C2):
C1─C2─C3─C4─A─B A, B — коммиты фичи (3, 4 — промежуточные «WIP»)
Merge commit: Squash and merge: Rebase and merge:
C1─C2───────M C1─C2───────S C1─C2─A'─B' \ / A'=A переписан C3─C4 без merge-коммита A────B S = один коммит (вся ветка «feat: login form» сохранена)После любого из вариантов ветку PR обычно удаляют — история фиксируется в main, а feature-ветки живут неделями, не годами.
Теги и GitHub Releases
Заголовок раздела «Теги и GitHub Releases»Теги — неизменяемые указатели на коммиты. Для релизов используют аннотированные теги (хранят автора, дату, сообщение):
git tag -a v1.4.0 -m "Release 1.4.0: dark theme, export to CSV"git push origin v1.4.0 # теги пушатся отдельно!git push origin --tags # или все разомистория main: C1─C2─C3─C4─C5 ▲ └─ tag: v1.4.0 (аннотированный, не двигается)По тегам принято ориентироваться в семантическом версионировании (MAJOR.MINOR.PATCH; спецификация — semver.org): ломающие изменения — новый MAJOR, фичи — MINOR, багфиксы — PATCH. Сам тег — неизменяемый: если обнаружил баг в v1.4.0, исправление выходит как v1.4.1, а тег не переставляется (переписывание опубликованных тегов ломает чужие сборки и блокировки зависимостей).
GitHub Release — это тег + страница с changelog, собранными артефактами и ссылками на скачивание:
# через gh CLI — удобно для релизного пайплайнаgh release create v1.4.0 \ --title "v1.4.0 — Dark theme" \ --notes-file CHANGELOG.md \ dist/app.tar.gzВ продакшене тег обычно ставит CI после успешного деплоя: код ушёл на серверы → только тогда v1.4.0 получает тег. Деплой «по тегу», а не «по main», даёт возможность откатиться: git checkout v1.3.2 — и раскатываешь старую версию.
Типичные ошибки и грабли
Заголовок раздела «Типичные ошибки и грабли»-
Пушить в main вместо своей ветки. После
git commitна main быстрыйgit pushотправляет прямо в защищённую ветку. Лечится дисциплиной «сначалаgit switch -c, потом код», либо branch protection на сервере. -
git pullпо привычке вместоfetch. Если под руками есть незакоммиченные правки, merge из pull может устроить конфликт в полусделанной работе. Сначалаfetch, оцениgit log main..origin/main, потом решай. -
--forceвместо--force-with-lease. Обычный форс не проверяет, что на сервере никто не успел запушить.--force-with-leaseоткажет, если серверная ветка сдвинулась с момента твоего последнего fetch — не даст случайно снести чужую работу. -
Забыть
-uпри первом push ветки. Тогдаgit pushв следующий раз ругнётся «no upstream». Исправление:git push -u origin веткаилиgit branch --set-upstream-to=origin/ветка. -
Коммитить секреты, полагаясь на «потом удалю». Удаление коммита из истории — это rebase/force-push по всем, кто уже склонировал. Включая ботов, которые мгновенно сканируют свежие пуши. См. главу «Продвинутый Git» про gitleaks.
-
Держать ветки неделями без синхронизации. Чем дольше ветка живёт врозь от main, тем дороже ребейз/мерж и тем больше шанс конфликтов. Интегрируйся маленькими порциями — ребейз на свежую main каждые 1–2 дня.
Вопросы на собеседовании
Заголовок раздела «Вопросы на собеседовании»-
Чем fetch отличается от pull?
fetchскачивает объекты и обновляет remote-tracking ветки, не трогая рабочую директорию и локальные ветки.pull=fetch+merge(илиrebase) апстрим-ветки в текущую. Fetch безопасен всегда, pull меняет историю. -
Что такое remote-tracking ветка? Локальный снимок состояния ветки на remote (
refs/remotes/origin/main). Обновляется только при синхронизации; сравнениеmain..origin/mainпоказывает, что пришло с сервера. -
Почему push иногда отказывает с non-fast-forward? Серверная ветка ушла вперёд, твоя история разошлась. Пуш стал бы перезаписью и потерял бы чужие коммиты. Решение:
pull --rebaseи push заново, либо--force-with-lease, если перезапись сознательная. -
Для чего нужен форк? Чтобы работать по fork-воркфлоу: у разработчика нет прав на запись в оригинал, поэтому он пушит в свой форк и предлагает изменения через PR. Плюс изоляция CI-квот и веток.
-
Чем squash merge отличается от rebase merge и merge commit? Squash схлопывает всю ветку в один коммит (чистая main, теряется гранулярность). Merge commit сохраняет всю топологию ветки. Rebase merge переносит коммиты поверх main линейно, без коммита слияния.
-
Как правильно обновить PR после замечаний ревьюера? Правки в ту же ветку и
git push(или amend +--force-with-lease). PR обновляется автоматически. Новый PR создавать не нужно. -
Что пушится командой
git pushбез аргументов? Текущая ветка в её апстрим (branch.X.remote/merge). Если апстрим не настроен — ошибка; настроить:git push -u origin ветка.
Практика
Заголовок раздела «Практика»- Создай тестовый репозиторий на GitHub (или локальный bare-репозиторий через
git init --bare). Склонируй его, сделай 3 коммита в новой ветке и запушь с-u. Критерий:git branch -vvпоказывает tracking-связь. - Сэмулируй конфликт push: запушь ветку из одной копии репозитория, затем из второй копии сделай другой коммит и попробуй запушить. Критерий: ты видишь отказ non-fast-forward и исправляешь его через
pull --rebaseбез потери коммитов. - Настрой два remote (
originиupstream) в одном репозитории. Сделай fetch из обоих и покажи разницуgit log origin/main..upstream/main. Критерий: оба remote отображаются вgit remote -v. - Создай PR в своём репозитории с тремя коммитами, из них один с сообщением «WIP». Проведи squash merge. Критерий: в main остался ровно один коммит с осмысленным сообщением.
- Поставь аннотированный тег
v0.1.0на текущий коммит и запушь его. Критерий: тег виден вgit ls-remote --tags originи на странице репозитория.