Урок курса
Синхронизация без сюрпризов pull
Git и GitHub с нуля: бесплатный курс с практикойВ прошлом уроке git fetch origin получал новые коммиты в репозиторий, не трогая рабочие файлы: origin/main обновлялась, локальная main оставалась на месте. Следующий вопрос — как объединить то, что пришло, с тем, что есть локально, и сделать это предсказуемо.
git pull: fetch плюс объединение — и почему без флага это опасно
git pull — не отдельная операция, а сочетание двух уже знакомых действий: сначала git fetch origin получает новые коммиты и обновляет remote-tracking ветку origin/main, затем Git объединяет origin/main с текущей локальной веткой.
Проблема в слове «объединяет». Как именно — зависит от флагов команды и настроек Git, в том числе pull.rebase. Они могут быть заданы глобально или для конкретного репозитория:
- Если
pull.rebase=false— Git делает merge. - Если
pull.rebase=true— Git делает rebase. - При расходящейся истории и отсутствии настроек или флагов, задающих способ объединения, современный Git может потребовать явного выбора. Само отсутствие
pull.rebaseне означает отказа: при fast-forward или уже согласованной истории выбирать между merge и rebase не требуется.
На вашей машине настройка может быть одна, на машине коллеги — другая. Один и тот же git pull на одной и той же истории даёт разный результат в зависимости от того, кто его запускает. Это и есть источник неожиданностей.
Чтобы поведение не зависело от чужих конфигов, нужно всегда передавать явный флаг. В этом уроке используются два подхода: git pull --ff-only для случая, когда история линейна, и явные git fetch origin + git merge origin/main — когда истории разошлись.
git pull --ff-only: fast-forward без merge-коммита
Флаг --ff-only разрешает объединение только одним способом — fast-forward. Это значит, что локальная main просто «догоняет» origin/main по уже существующей линейной истории: никакого нового коммита слияния не создаётся, HEAD перемещается вперёд.
Условие продвижения main к более новой origin/main: у локальной main нет коммитов, которых нет в origin/main. Иначе говоря, локальная ветка является прямым предком удалённой. В этом случае Git продвигает указатель main, обновляет индекс и рабочие файлы.
Для отдельного опыта распакуйте весь git_practice.zip в новую пустую папку. Откройте терминал в её git_practice или перейдите туда через cd "полный путь к новой папке/git_practice", подставив свой путь. Выданный remote/shared.git содержит чистую main с одним исходным коммитом A и README: заголовок «# Командный справочник», контакт «команда», статус «черновик», между ними пустые строки. Папки clone1 и clone2 ещё не существуют; git init не нужен.
Создайте оба клона до первой отправки и настройте автора отдельно в каждом:
git clone remote/shared.git clone1
git clone remote/shared.git clone2
cd clone1
git config user.name "Учебный автор 1"
git config user.email "learner1@example.invalid"
git status
git log --oneline main
git log --oneline origin/main
cd ../clone2
git config user.name "Учебный автор 2"
git config user.email "learner2@example.invalid"
git status
git log --oneline main
git log --oneline origin/main
В обоих клонах должны быть On branch main, чистая рабочая папка и один одинаковый коммит A в обоих логах. Clone установил upstream origin/main; не меняйте настройки remote и tracking. Записи ID и наблюдений храните вне клонов. В этом опыте никто больше не меняет remote.
В clone2 откройте README редактором, сохраняя исходные окончания строк. После строки статуса добавьте пустую строку и Версия: 1, сохраните файл. Опубликуйте коммит B:
git add README.md
git commit -m "Добавить версию"
git push
git log --oneline
cd ../clone1
git status
git pull --ff-only
Перед pull clone1 чист и не имеет своих новых коммитов. Запишите ID B из лога clone2 и сравните с вершиной main в clone1 после команды. Синтаксис самой команды:
git pull --ff-only
Вывод при продвижении содержит Fast-forward. Ниже условные ID; число добавленных строк зависит также от пустых строк и сохранённых окончаний строк:
Updating a1b2c3d..e4f5a6b
Fast-forward
README.md | 1 +
1 file changed, 1 insertion(+)
После этого:
git log --onelineпоказывает коммит B на вершинеmain;- README в clone1 содержит строку «Версия: 1»;
git statusчист — ни staged, ни unstaged изменений нет;- нового merge-коммита в графе нет.
Если повторить git pull --ff-only сейчас, обновлять уже нечего: допустим успешный ответ Already up to date. Такой же ответ возможен, когда main только опережает полученную ветку без расхождения; наличие одного локального коммита само по себе не требует отказа.
Fast-forward работает именно потому, что clone1 не добавлял ничего своего поверх исходного коммита A. origin/main просто ушла вперёд, и Git переместил main в ту же точку.
Отказ --ff-only при расходящейся истории: два этапа, два разных результата
Расходящаяся история — это когда у main и origin/main есть коммиты, которых нет в другой. Ни одна из вершин не является предком другой: история разветвилась.
Подготовьте эту развилку в тех же двух клонах после получения B. В clone1 добавьте в конец README строку Правка: локальная и сохраните исходные окончания строк. Затем:
git add README.md
git commit -m "Локальная правка"
git log --oneline
git status
cd ../clone2
Запишите ID нового коммита C. В clone2, который всё ещё на B, независимо добавьте в тот же конец README Правка: коллега:
git add README.md
git commit -m "Правка коллеги"
git push
git log --oneline
cd ../clone1
git log --oneline main
git log --oneline origin/main
git status
git diff
git diff --staged
git pull --ff-only
Запишите ID D из clone2. Перед pull в clone1 чистая main на C, а ещё устаревшая origin/main на B; README содержит локальную правку, оба diff пусты. До этого pull не получайте D отдельно. Два этапа команды независимы по результату.
Первый этап — fetch. Git связывается с origin, получает новые объекты и обновляет origin/main. В данном опыте получение успешно завершается до проверки возможности объединения. Если само получение завершится ошибкой, выводы об актуальности ссылки делать нельзя.
Второй этап — объединение. Если fast-forward невозможен, Git отказывает именно здесь. Типичное сообщение:
fatal: Not possible to fast-forward, aborting.
Что при этом остаётся нетронутым:
- локальная
main— по-прежнему указывает на коммит C («Локальная правка»); - рабочие файлы — README содержит «Правка: локальная», ничего не изменилось;
- индекс — чистый, как был.
Что уже изменилось в репозитории:
origin/mainобновлена и теперь указывает на коммит D («Правка коллеги»);- объекты коммита D и его дерева уже получены и хранятся локально.
Проверить это можно сразу после отказа:
git log --graph --oneline --all
Граф покажет два расходящихся пути: main на C, origin/main на D, общий предок — B.
Важный вывод: отказ этапа объединения не «откатывает» fetch. Репозиторий уже знает о D, даже если main к нему не прикоснулась. Поэтому нельзя делать вывод об отсутствии любых изменений по одному лишь неизменному git log текущей ветки.
git fetch origin + git merge origin/main: явное объединение расходящихся веток
После того как pull --ff-only отказал, origin/main уже актуальна — fetch выполнен. Но повторить его не вредно: если с момента отказа в origin появились новые коммиты, повторный fetch их подхватит.
git fetch origin
git merge -m "Объединить правки" origin/main
Здесь merge объединяет текущую main (коммит C) и origin/main (коммит D). У них общий предок B, поэтому Git попытается применить изменения обеих сторон.
Возможный конфликт. Оба коммита добавляли строки в конец одного файла README. Git видит два вставленных блока в одном участке и не знает, какой из них «правильный». В этом случае git merge остановится с сообщением о конфликте, а git status покажет:
You have unmerged paths.
(fix conflicts and run "git commit")
Unmerged paths:
(use "git add <file>..." to mark resolution...)
both modified: README.md
README будет содержать маркеры:
<<<<<<< HEAD Правка: локальная ======= Правка: коллега >>>>>>> origin/main
Нужно открыть файл в редакторе, убрать маркеры и оставить обе строки:
# Командный справочник Контакт: команда Статус: черновик Версия: 1 Правка: локальная Правка: коллега
Затем зафиксировать разрешение:
git add README.md
git commit -m "Объединить правки"
Если Git объединил автоматически (правки не перекрылись), дополнительный commit не нужен — merge уже завершён и коммит создан с переданным сообщением.
После завершения:
git log --graph --oneline --all
Вывод покажет merge-коммит с двумя родителями — C и D. ID ниже условные; сообщения и родительство сопоставьте со своими записями:
* f7a1234 Объединить правки |\ | * d3e5678 Правка коллеги * | c2b4567 Локальная правка |/ * b1a2345 Добавить версию * a0f1234 Initial commit
git status чист, README содержит «Версию: 1», «Правку: локальная» и «Правку: коллега». Отправьте результат из clone1 и обновите чистую main в clone2:
git push
cd ../clone2
git fetch origin
git merge origin/main
git status
git log --graph --oneline --all
Оба клона теперь имеют одинаковую вершину main и актуальную origin/main, версию и обе правки в README. Сравнивайте фактические ID, а не условные хеши рисунка.
Задача 1
Чистая местная main и origin/main находятся на А. Коллега отправил потомка Б в общую main. Upstream настроен; местных новых коммитов нет. Обычный pull --ff-only успешно завершился. Каков итог?
Задача 2
Изначально origin/main на общем предке А, чистая местная main на своём потомке Л. Коллега отправил другого потомка К. После pull --ff-only получен отказ fast-forward. Какое изменение могло уже произойти?
Задача 3
Самостоятельная практика:
Настоящая локальная практика Git на своём компьютере. Площадка не запускает команды и не проверяет репозиторий.
Входные данные. Распакуйте выданный git_practice.zip в новую пустую папку и перейдите в git_practice. remote/shared.git имеет main, один исходный коммит и README с заголовком «# Командный справочник», контактом «команда», статусом «черновик». Создайте оба clone и явно задайте оба поля автора до новых коммитов:
git clone remote/shared.git clone1
git clone remote/shared.git clone2
cd clone1
git config user.name "Учебный автор 1"
git config user.email "learner1@example.invalid"
cd ../clone2
git config user.name "Учебный автор 2"
git config user.email "learner2@example.invalid"
cd ..
Оба clone на чистой main с upstream origin/main; init не нужен.
Часть 1. В clone2 добавьте в конец README «Версия: 1», выполните git add README.md, git commit -m "Добавить версию", git push. В clone1 выполните git pull --ff-only. Подтвердите fast-forward: main и файл обновились до опубликованного коммита, новый merge-коммит не создан, версия видна, status чист.
Часть 2. В clone1 поверх полученного B добавьте в конец README «Правка: локальная» и сделайте коммит C. В clone2 поверх B независимо добавьте в тот же конец «Правка: коллега», сделайте коммит D и отправьте. В clone1 до этого D не получайте. Сохраните исходные log main, граф --all, README, status, diff и diff --staged; рабочая папка и индекс чистые.
В clone1 выполните pull --ff-only и запишите фактический отказ. Проверьте: main и README остались на C, индекс прежний, но origin/main после fetch указывает на полученный D; граф --all показывает расхождение. Не делайте вывод об отсутствии любых изменений репозитория по одному неизменному локальному log.
Явно выполните fetch origin и merge -m "Объединить правки" origin/main. Проверяйте status и README: вставки в тот же участок могут конфликтовать. При конфликте соберите текст с обеими строками правок, сохраните исходные строки и версию, удалите маркеры, выполните add README.md и commit -m "Объединить правки". Если merge уже завершён автоматически, дополнительный commit не нужен.
Подтвердите обе правки в README, merge-коммит с двумя родителями и чистый status. Отправьте main из clone1; в чистой main clone2 выполните fetch origin и merge origin/main. Проверьте одинаковое итоговое содержимое README, согласованные main и актуальные origin/main обоих clone, чистые status/diff/staged diff. Приложите сравнение указателей до/после отказа и доказательство завершения merge; заранее заданных SHA и дат нет.
Доступные средства: редактор, git clone, git config user.name/user.email, git add, git commit -m, git push, git pull --ff-only, git fetch origin, git merge -m, git merge origin/main, git log --oneline, git log --graph --oneline --all, git status, git diff, git diff --staged. Маркеры/add/commit разрешения уже изучены в lesson_17.
Письменное объяснение:
Коллега говорит: «Я всегда использую просто git pull без флагов — зачем усложнять? Ведь Git сам разберётся, что делать». Объясните, почему git pull без явного флага может давать разный результат на разных машинах, и какие два подхода позволяют сделать поведение предсказуемым: один для линейной истории, другой для расходящейся.
Платформа проверяет только ответ ниже; действия в своём репозитории и письменные ответы вы проверяете самостоятельно.
Контрольный случай:
В другом clone после разных изменений одной строки описания pull --ff-only отказал. Затем выполнили merge origin/main. При осмотре README нашли блок с <<<<<<< HEAD, ======= и >>>>>>> origin/main; status показывает unmerged paths для этого файла. После merge других команд не выполняли. Можно ли уже выполнить обычный push и считать синхронизацию завершённой?
Продолжить с сохранением прогресса
Перейдите к уроку в учебном кабинете.
