voidlearn
 

4.1Remote

Сопоставишь локальную ветку, remote-tracking ref и состояние сервера на момент последнего fetch. Разберёшь refspec, отказ push и защиту от затирания чужих коммитов.

Что сможешь объяснить после этой главы
  • Что git clone пишет в конфиг и почему локальная ветка после клона одна.
  • Как читается refspec и почему в клоне с --depth 1 не находится соседняя ветка.
  • Чем origin/main отличается от main, что меняет fetch и что добавляет pull.
  • Почему сервер отклоняет push и чем «fetch first» отличается от «non-fast-forward».
  • Когда --force-with-lease спасает чужой коммит, когда нет и что к нему добавляет --force-if-includes.

Клон: имя, адрес и ветки сервера

Примеры главы идут на Go-сервисе shop. Сервер стоит на той же машине: git http-backend отдаёт голый репозиторий по smart HTTP, как GitLab и GitHub при клоне по HTTPS. Боря уже отправил туда main с двумя коммитами и ветку metrics. Второго сентября Аня клонирует проект:

$ git clone http://localhost:8765/shop.git
Cloning into 'shop'...
remote: Enumerating objects: 12, done.
remote: Counting objects: 100% (12/12), done.
remote: Compressing objects: 100% (11/11), done.
remote: Total 12 (delta 2), reused 0 (delta 0), pack-reused 0 (from 0)
Receiving objects: 100% (12/12), done.
Resolving deltas: 100% (2/2), done.
$ cd shop
$ git remote -v
origin	http://localhost:8765/shop.git (fetch)
origin	http://localhost:8765/shop.git (push)
$ git branch -a
* main
  remotes/origin/HEAD -> origin/main
  remotes/origin/main
  remotes/origin/metrics

Строки с remote: печатает сервер, пока собирает pack-файл (глава 1.4), остальные печатает клиент, пока его принимает. Такие счётчики есть в каждом обмене, где передаются объекты, дальше в выводе push и fetch они опущены. Клон по пути к каталогу (git clone ../srv/shop.git) идёт в обход протокола, после Cloning into там только done.: git копирует объекты напрямую, связывая файлы жёсткими ссылками.

clone заводит один remote, origin, и git remote -v печатает его адрес дважды: откуда забирать и куда отправлять. Адреса разойдутся, если задать отдельный remote.origin.pushurl. Сервер отдал все ветки, но локальная появилась одна. Остальные лежат под remotes/origin/: это remote-tracking ветки. В конфиг клон дописал вот что:

$ tail -n 6 .git/config
[remote "origin"]
	url = http://localhost:8765/shop.git
	fetch = +refs/heads/*:refs/remotes/origin/*
[branch "main"]
	remote = origin
	merge = refs/heads/main

url говорит, куда ходить. fetch хранит refspec. Секция [branch "main"] назначает ветке upstream, а про origin/HEAD было в главе 1.2.

Remote бывает и не один. В клоне форка исходный проект подключают вторым, git remote add upstream <адрес>, и его ветки едут в refs/remotes/upstream/. Это имя из документации GitHub, с upstream ветки из этой главы оно не связано. Если ветка есть в обоих remote, git switch metrics откажет (matched multiple), и remote называют сами: git switch --track origin/metrics или checkout.defaultRemote=origin в конфиге.

Refspec: какие ссылки куда класть

Строку +refs/heads/*:refs/remotes/origin/* делит двоеточие. Слева ссылки на сервере, справа имена, под которыми они лягут в клоне, а звёздочка переносит хвост имени: refs/heads/metrics сервера становится refs/remotes/origin/metrics. Плюс разрешает перезаписать локальную ссылку, даже если новое значение не потомок старого. Без плюса fetch отказался бы обновить origin/metrics после чужого rebase и напечатал бы ! [rejected] metrics -> origin/metrics (non-fast-forward). Какие ссылки есть на сервере, показывает git ls-remote, ничего не скачивая:

$ git ls-remote origin
af76ac493a46ae06b87e04e9efa081d5da3f693b	HEAD
af76ac493a46ae06b87e04e9efa081d5da3f693b	refs/heads/main
1d477fd3a5b0963b561de14c333507dccd8ad1ac	refs/heads/metrics
1d477fd3a5b0963b561de14c333507dccd8ad1ac	refs/merge-requests/1/head

Последняя ссылка устроена как на GitLab, который заводит refs/merge-requests/<номер>/head на каждый merge request (здесь её создали руками). Под refs/heads/* она не подпадает, поэтому в клон не приехала. Второе правило дописывают ещё одной строкой fetch:

$ git config --add remote.origin.fetch '+refs/merge-requests/*/head:refs/remotes/origin/mr/*'
$ git fetch
From http://localhost:8765/shop
 * [new ref]         refs/merge-requests/1/head -> origin/mr/1
$ git branch -r
  origin/HEAD -> origin/main
  origin/main
  origin/metrics
  origin/mr/1

Теперь код merge request открывается локально через git switch --detach origin/mr/1, даже если своей ветки у него в этом репозитории нет. Refspec можно написать и прямо в команде: git fetch origin metrics значит «забери ветку metrics», правая часть пустая.

$ git fetch origin metrics
From http://localhost:8765/shop
 * branch            metrics    -> FETCH_HEAD

Результат лёг в .git/FETCH_HEAD. Если ветка подходит под правило из remote.origin.fetch, git с версии 1.8.4 попутно сдвигает и её remote-tracking ветку (здесь origin/metrics и так свежая). Без правила remote-tracking ветки нет.

В клоне с --depth 1 соседней ветки нет

--depth включает --single-branch, и clone записывает правило только для одной ветки. Забрать другую по имени получится, но remote-tracking ветки у неё не будет:

$ git clone -q --depth 1 http://localhost:8765/shop.git
$ cd shop
$ git config --get-all remote.origin.fetch
+refs/heads/main:refs/remotes/origin/main
$ git fetch origin metrics
From http://localhost:8765/shop
 * branch            metrics    -> FETCH_HEAD
$ git switch metrics
fatal: invalid reference: metrics
$ git remote set-branches --add origin metrics
$ git fetch
From http://localhost:8765/shop
 * [new branch]      metrics    -> origin/metrics

set-branches --add дописал второе правило, и ветка приехала. На том же спотыкаются шаги CI, которые сравнивают ветку merge request с origin/main в неглубоком клоне: нет правила, нет и origin/main. Про глубину истории в CI есть вопрос в главе 1.4.

fetch: забрать и ничего не трогать

Пока Аня возилась с refspec, Боря отправил в main коммит. Клон Ани об этом не знает:

$ git status
On branch main
Your branch is up to date with 'origin/main'.

nothing to commit, working tree clean

«Up to date» значит «совпадает с origin/main», а origin/main показывает туда же, куда при клонировании. status в сеть не ходит. Сервер спрашивает git fetch:

$ git fetch
From http://localhost:8765/shop
   af76ac4..d088609  main       -> origin/main
$ git status
On branch main
Your branch is behind 'origin/main' by 1 commit, and can be fast-forwarded.
  (use "git pull" to update your local branch)

nothing to commit, working tree clean

Строка fetch описывает одну ссылку: флаг, старое и новое значение, откуда и куда. Флаги из этой главы:

ФлагЧто случилось со ссылкойКак выглядит
пробелперемотана вперёдaf76ac4..d088609 main -> origin/main
*новая ссылка[new branch] metrics -> origin/metrics
+перезаписана не вперёд, между хешами три точкиb6a0d07...a0dc01d retry -> origin/retry (forced update)
-удалена при --prune[deleted] (none) -> origin/metrics
tтег переставлен[tag update] v0.2.0 -> v0.2.0, глава 4.3
!отклонена[rejected] metrics -> origin/metrics (non-fast-forward)

fetch докачал недостающие объекты и сдвинул origin/main. Локальная main, индекс и файлы остались как были: ветки под refs/heads/ fetch трогает, только если правая часть refspec прямо на них указывает. Поэтому его можно звать когда угодно, хоть посреди незакоммиченной работы. Что пришло, показывает диапазон из главы 1.2, а список всего принесённого лежит в FETCH_HEAD (первая колонка с хешами здесь отрезана):

$ git log --oneline main..origin/main
d088609 (origin/main, origin/HEAD) Отдавать версию в /health
$ cut -f 2- .git/FETCH_HEAD
	branch 'main' of http://localhost:8765/shop
not-for-merge	branch 'metrics' of http://localhost:8765/shop
not-for-merge	'refs/merge-requests/1/head' of http://localhost:8765/shop

Первая строка без пометки not-for-merge: это upstream текущей ветки, её и вливает git pull. Аня вливает сама, и слияние выходит перемоткой (глава 3.1):

$ git merge origin/main
Updating af76ac4..d088609
Fast-forward
 health.go | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)
Сервер: shop.git Клон Ани: .git Рабочая копия main af76ac4 d088609 push Бори: main → d088609 main origin/main af76ac4 d088609 fetch принёс d088609 и сдвинул origin/main main остался на af76ac4 снимок af76ac4 health.go: "ok" fetch файлы не трогает, их обновит merge git fetch merge
Одна ветка в трёх местах. Push Бори сдвинул main на сервере. fetch принёс новый коммит в клон Ани и сдвинул только origin/main, а main и файлы догонят сервер после слияния.

Upstream: с какой веткой сервера связана твоя

Аня заводит ветку для повторов запросов к базе и пишет store.go. У новой ветки нет пары на сервере, и push без аргументов не знает, куда отправлять:

$ git switch -c retry
Switched to a new branch 'retry'
$ git add store.go
$ git commit -qm "Повторять запрос к базе"
$ git push
fatal: The current branch retry has no upstream branch.
To push the current branch and set the remote as upstream, use

    git push --set-upstream origin retry

To have this happen automatically for branches without a tracking
upstream, see 'push.autoSetupRemote' in 'git help config'.
$ git push -u origin retry
To http://localhost:8765/shop.git
 * [new branch]      retry -> retry
branch 'retry' set up to track 'origin/retry'.
$ git config --get-regexp '^branch\.retry\.'
branch.retry.remote origin
branch.retry.merge refs/heads/retry

Эти две строки и задают upstream. В клоне его представляет origin/retry. -u (длинно --set-upstream) записал их при первом push, и дальше по ним работают команды без аргументов: push и pull знают, куда и откуда, status знает, с чем сравнивать. В командах upstream пишется @{u}:

$ git commit -qam "Ограничить число повторов"
$ git status -sb
## retry...origin/retry [ahead 1]
$ git rev-parse --abbrev-ref @{u}
origin/retry
$ git rev-list --left-right --count @{u}...HEAD
0	1
$ git log --oneline @{u}..
c52f3d6 (HEAD -> retry) Ограничить число повторов
$ git push
To http://localhost:8765/shop.git
   6e5288e..c52f3d6  retry -> retry

Ahead и behind git считает обходом с тремя точками из главы 1.2: слева коммиты только upstream, справа только твоей ветки. Считает по origin/retry, так что «behind» без fetch устаревает, а вот git log @{u}.. не врёт и без него: свой push сдвигает origin/retry сам.

Upstream появляется и без -u. Если локальной ветки нет, а remote-tracking с таким именем есть ровно в одном remote, git switch создаёт ветку и сразу связывает её:

$ git switch metrics
branch 'metrics' set up to track 'origin/metrics'.
Switched to a new branch 'metrics'
$ git branch -vv
  main    d088609 [origin/main] Отдавать версию в /health
* metrics 1d477fd [origin/metrics] Считать запросы
  retry   c52f3d6 [origin/retry] Ограничить число повторов

Куда уходит git push без аргументов, решает push.default. С git 2.0 там simple: git отправляет текущую ветку в её upstream, и только если имена совпадают. В другой remote (git push fork) ветка уйдёт в одноимённую и без upstream. Второе условие всплывает, когда ветку начинают прямо от remote-tracking:

$ git switch -c hotfix origin/main
branch 'hotfix' set up to track 'origin/main'.
Switched to a new branch 'hotfix'

Upstream ветки hotfix теперь origin/main, и push по upstream ушёл бы прямо в main сервера. git push отказывается со словами fatal: The upstream branch of your current branch does not match the name of your current branch. Чинят одним из двух способов: git push -u origin hotfix перепишет upstream на origin/hotfix, а ветка, созданная с --no-track, его вовсе не получит. Настройка push.autoSetupRemote=true (git 2.37) сама добавит -u при первом push ветки без upstream, и после --no-track хватит простого git push. Ветку hotfix после опыта Аня удалила и вернулась в main.

Push: сервер ждёт перемотку

Третьего сентября Аня коммитит в main логирование запросов. Боря в это время поднимает таймаут сервера и отправляет первым. Push Ани:

$ git push
To http://localhost:8765/shop.git
 ! [rejected]        main -> main (fetch first)
error: failed to push some refs to 'http://localhost:8765/shop.git'
hint: Updates were rejected because the remote contains work that you do not
hint: have locally. This is usually caused by another repository pushing to
hint: the same ref. If you want to integrate the remote changes, use
hint: 'git pull' before pushing again.
hint: See the 'Note about fast-forwards' in 'git push --help' for details.
$ git fetch
From http://localhost:8765/shop
   d088609..23d68bc  main       -> origin/main
$ git push
To http://localhost:8765/shop.git
 ! [rejected]        main -> main (non-fast-forward)
error: failed to push some refs to 'http://localhost:8765/shop.git'
hint: Updates were rejected because the tip of your current branch is behind
hint: its remote counterpart. If you want to integrate the remote changes,
hint: use 'git pull' before pushing again.
hint: See the 'Note about fast-forwards' in 'git push --help' for details.
$ git status -sb
## main...origin/main [ahead 1, behind 1]

Push начинается с того, что сервер перечисляет свои ссылки с хешами. Клиент сравнивает вершину сервера с тем, что собирается отправить, и без флагов принуждения пропускает только перемотку (глава 3.1): новая вершина должна быть потомком старой. Иначе коммит, на который указывала ветка сервера, выпал бы из неё.

Два текста отказа различаются тем, что клиент знает о чужом коммите. В первый раз 23d68bc у Ани не было, и проверить родство git не мог: fetch first. После fetch коммит есть, и ответ точный:

$ git merge-base --is-ancestor origin/main main; echo $?
1

origin/main не предок main, ветки разошлись: non-fast-forward. Проверять умеет и сам сервер. Голый репозиторий с receive.denyNonFastForwards=true не пропустит даже --force: объекты он примет, ссылку не сдвинет и ответит ! [remote rejected] main -> main (non-fast-forward). Защищённые ветки GitLab и GitHub по умолчанию так же запрещают принудительный push (глава 4.2).

pull: fetch, а потом merge или rebase

git pull запускает git fetch и вливает upstream в текущую ветку. Пока ветки не разошлись, он просто перематывает. Разошлись, и git 2.54 без настройки не выбирает за тебя:

$ git pull
hint: You have divergent branches and need to specify how to reconcile them.
hint: You can do so by running one of the following commands sometime before
hint: your next pull:
hint:
hint:   git config pull.rebase false  # merge
hint:   git config pull.rebase true   # rebase
hint:   git config pull.ff only       # fast-forward only
hint:
hint: You can replace "git config" with "git config --global" to set a default
hint: preference for all repositories. You can also pass --rebase, --no-rebase,
hint: or --ff-only on the command line to override the configured default per
hint: invocation.
fatal: Need to specify how to reconcile divergent branches.

Отказ появился в git 2.33.1. Раньше pull делал merge-коммит, а с 2.27 ещё и предупреждал об этом. Вот что получается слиянием:

$ git pull --no-rebase
Merge made by the 'ort' strategy.
 main.go | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)
$ git log --oneline --graph -4
*   282f10f (HEAD -> main) Merge branch 'main' of http://localhost:8765/shop
|\
| * 23d68bc (origin/main, origin/HEAD) Поднять таймаут до 5 секунд
* | b120e8a Логировать запросы
|/
* d088609 Отдавать версию в /health

Ветка main слилась сама с собой. За узлом «Merge branch 'main' of …» нет никакого решения, два человека просто коммитили в одну ветку в одно и то же утро, а log --graph и bisect теперь обходят лишнюю развилку. Аня отменяет слияние (reset разобран в главе 5.1) и переставляет свой коммит поверх коммита Бори:

$ git reset --hard ORIG_HEAD
HEAD is now at b120e8a Логировать запросы
$ git pull --rebase
Successfully rebased and updated refs/heads/main.
$ git log --oneline --graph -3
* 2db9371 (HEAD -> main) Логировать запросы
* 23d68bc (origin/main, origin/HEAD) Поднять таймаут до 5 секунд
* d088609 Отдавать версию в /health
$ git push
To http://localhost:8765/shop.git
   23d68bc..2db9371  main -> main

У коммита Ани новый хеш 2db9371, потому что у него новый родитель (глава 3.3). Переписывать его можно было спокойно: b120e8a на сервер не попал. Чтобы не выбирать каждый раз, способ задают в конфиге:

НастройкаВетки разошлисьКому подходит
pull.rebase=trueсвои коммиты переносятся поверх пришедшихобщая ветка, где нужна линейная история
pull.rebase=falsemerge-коммиттем, кто хочет видеть, где работа шла параллельно
pull.ff=onlyотказ после fetch: ветка и файлы не тронутытем, кто после fetch решает сам

Если в конфиге и pull.rebase=true, и pull.ff=only, git 2.54 на разошедшихся ветках откажет с Not possible to fast-forward, а вот --rebase в командной строке pull.ff=only перебивает. Вариант с rebase строже к рабочей копии: с незакоммиченными правками он не начнётся (cannot pull with rebase: You have unstaged changes), слияние же терпит правки в файлах, которых не касается. --autostash или rebase.autoStash=true уберут правки в stash (глава 2.2) на время pull и вернут после.

pull --rebase помнит, где стояла ветка сервера

Коллега переписал ветку, поверх которой лежат твои коммиты. git rebase origin/topic попробует перенести и его старые коммиты, а git pull --rebase и git rebase без аргументов ищут точку отсчёта в reflog remote-tracking ветки (git merge-base --fork-point) и переносят только сделанное поверх её прежней вершины. В опыте, где коллега исправил свой коммит, git rebase origin/topic встал на конфликте в этом коммите, а git pull --rebase перенёс один коммит без вопросов. Вручную то же делает rebase --onto из главы 3.3.

--force, --force-with-lease и --force-if-includes

Отказ non-fast-forward бывает ожидаемым: ты сам переписал свою ветку. У Ани открыт merge request из retry, а main за это время ушла вперёд. Четвёртого сентября в 10:00 Боря прямо на ревью дописал в её ветку проверку отмены контекста и отправил. В 10:30 Аня, не делая fetch, переносит ветку на свежую main:

$ git switch retry
Switched to branch 'retry'
Your branch is up to date with 'origin/retry'.
$ git rebase main
Successfully rebased and updated refs/heads/retry.

Обычный git push отклонён с (fetch first), как в разделе выше: коммита Бори у Ани нет. --force снимает проверку целиком. Его прогоним на копии сервера и обоих клонов, а после опыта вернём всё как было:

$ git push --force
To http://localhost:8765/shop.git
 + b6a0d07...a0dc01d retry -> retry (forced update)

У Бори после fetch:

$ git fetch
From http://localhost:8765/shop
 + b6a0d07...a0dc01d retry      -> origin/retry  (forced update)
$ git log --oneline -3 origin/retry
a0dc01d (origin/retry) Ограничить число повторов
09f8682 Повторять запрос к базе
2db9371 (origin/main, origin/HEAD) Логировать запросы

Коммита b6a0d07 в ветке сервера больше нет, и никто ни о чём не предупредил. Он уцелел только в клоне Бори. Дальше снова настоящие репозитории, где --force не звали.

--force-with-lease ставит условие: перезаписать ветку сервера, только если она стоит там, где её последний раз видел твой клон, то есть на origin/retry. У Ани там c52f3d6, на сервере b6a0d07:

$ git push --force-with-lease
To http://localhost:8765/shop.git
 ! [rejected]        retry -> retry (stale info)
error: failed to push some refs to 'http://localhost:8765/shop.git'

Защита работает, пока origin/retry сдвигается только тогда, когда ты сам посмотрел на новые коммиты. Фоновый fetch это ломает: IDE, которая забирает изменения по таймеру (в VS Code это git.autofetch), или git fetch, набранный между делом:

$ git fetch
From http://localhost:8765/shop
   c52f3d6..b6a0d07  retry      -> origin/retry

Ветка Ани не изменилась, коммита Бори в ней нет, но ожидание lease теперь совпадает с сервером. git push --force-with-lease выкинул бы b6a0d07 так же, как --force: на копии --dry-run ответил forced update. Эту дыру закрывает --force-if-includes из git 2.30:

$ git push --force-with-lease --force-if-includes
To http://localhost:8765/shop.git
 ! [rejected]        retry -> retry (remote ref updated since checkout)
error: failed to push some refs to 'http://localhost:8765/shop.git'
hint: Updates were rejected because the tip of the remote-tracking branch has
hint: been updated since the last checkout. If you want to integrate the
hint: remote changes, use 'git pull' before pushing again.
hint: See the 'Note about fast-forwards' in 'git push --help' for details.

Второй флаг проверяет, что вершина origin/retry достижима из какой-нибудь записи reflog локальной retry (глава 5.2). Грубо говоря, этот коммит хоть раз был в твоей ветке, а b6a0d07 там не был. Сам по себе флаг ничего не делает: он работает только вместе с --force-with-lease без явного хеша.

23d68bc 2db9371 09f8682 a0dc01d main retry у Ани origin/retry retry на сервере d088609 6e5288e c52f3d6 b6a0d07 09f8682, a0dc01d: копии коммитов Ани после rebase b6a0d07: коммит Бори, в ветке Ани его нет git push --force сервер: retry → a0dc01d, коммит b6a0d07 выпал --force-with-lease ждёт c52f3d6, на сервере b6a0d07: stale info фоновый fetch, потом --force-with-lease ждёт b6a0d07 и дожидается: коммит Бори выпадет --force-with-lease --force-if-includes b6a0d07 нет в reflog retry: отказ
Кто спасает коммит Бори. Ветку Ани перенесли на свежую main, а на сервере в retry успел появиться коммит Бори. --force его выбрасывает. Lease спасает, пока origin/retry не сдвинул фоновый fetch, а --force-if-includes ещё и проверяет, был ли коммит в ветке Ани.

Чтобы push прошёл честно, коммит Бори надо забрать в ветку. Проще всего поставить ветку на серверную версию и повторить rebase:

$ git log --oneline ORIG_HEAD..origin/retry
b6a0d07 (origin/retry) Не повторять после отмены контекста
$ git reset --hard origin/retry
HEAD is now at b6a0d07 Не повторять после отмены контекста
$ git rebase main
Successfully rebased and updated refs/heads/retry.
$ git push --force-with-lease --force-if-includes
To http://localhost:8765/shop.git
 + b6a0d07...7ffc204 retry -> retry (forced update)

ORIG_HEAD после rebase хранит прежнюю вершину ветки (глава 3.3), поэтому диапазон показал ровно то, что добавили на сервере. После reset вершина b6a0d07 попала в reflog retry, и проверка прошла. Набирать оба флага не обязательно: git config push.useForceIfIncludes true добавляет второй к каждому --force-with-lease.

Подсказка «use git pull» здесь приводит к копиям коммитов из main

После отказа git советует git pull. На копии git pull --rebase перенёс на origin/retry всё, чего там не было, включая два коммита из main. В ветке появились копии «Поднять таймаут до 5 секунд» и «Логировать запросы» с новыми хешами, а push стал обычной перемоткой: его пропустил бы и простой git push, --force-if-includes ничего не решал. А аккуратный git cherry-pick origin/retry проверку не проходит: у копии другой хеш, и вершина сервера в reflog не попадает. Проверка смотрит на граф, а не на содержимое.

Удалить ветку на сервере и убрать следы у себя

Пятого сентября Боря влил metrics в main через squash (глава 3.3) и удалил ветку на сервере. GitLab и GitHub умеют удалять исходную ветку сами после слияния, если это включить. Из командной строки так:

$ git push origin --delete metrics
To http://localhost:8765/shop.git
 - [deleted]         metrics

Для git удаление тоже push, с пустой левой частью refspec: git push origin :metrics делает то же самое. Клон Ани об этом не узнал, и обычный fetch ему не расскажет:

$ git fetch
From http://localhost:8765/shop
   2db9371..525eda2  main       -> origin/main
$ git branch -a
  main
  metrics
* retry
  remotes/origin/HEAD -> origin/main
  remotes/origin/main
  remotes/origin/metrics
  remotes/origin/mr/1
  remotes/origin/retry
$ git fetch --prune
From http://localhost:8765/shop
 - [deleted]         (none)     -> origin/metrics

По умолчанию fetch remote-tracking ветки только заводит и сдвигает. --prune удаляет те, которых на сервере больше нет, вместе с их reflog. Локальная metrics при этом остаётся, только upstream у неё пропал:

$ git branch -vv
  main    2db9371 [origin/main: behind 1] Логировать запросы
  metrics 1d477fd [origin/metrics: gone] Считать запросы
* retry   7ffc204 [origin/retry] Не повторять после отмены контекста
$ git branch -d metrics
error: the branch 'metrics' is not fully merged
hint: If you are sure you want to delete it, run 'git branch -D metrics'
hint: Disable this message with "git config set advice.forceDeleteBranch false"
$ git branch -D metrics
Deleted branch metrics (was 1d477fd).

-d удаляет ветку, только если она влита в свой upstream, а без upstream в HEAD. После squash в main лежит новый коммит с тем же содержимым, а 1d477fd по графу никуда не влит. Где сливают через squash, -D приходится звать самому, сверившись с main. Ветки с пропавшим upstream для скрипта перечисляет git for-each-ref --format='%(refname:short) %(upstream:track)' refs/heads, у них в конце стоит [gone].

Чтобы не вспоминать про --prune, его включают в конфиге: git config fetch.prune true в этом клоне, с --global во всех. Теги так не чистятся, им нужен отдельный --prune-tags (глава 4.3).

Вопросы

4
Суть: fetch скачивает объекты и сдвигает remote-tracking ветки, больше ничего: локальные ветки, индекс и файлы остаются как были. pull делает тот же fetch и сразу вливает upstream в текущую ветку слиянием или rebase. Когда двое коммитят в одну ветку, слияние склеивает её саму с собой, отсюда «Merge branch 'main' of …».

Что меняет каждая команда

Чтоgit fetchgit pull
объектыдокачиваетдокачивает
refs/remotes/origin/*, FETCH_HEADобновляетобновляет
текущая веткане трогаетперематывает, сливает или переносит коммиты
индекс и рабочая копияне трогаетобновляет, может остановиться на конфликте

Откуда лишние merge-коммиты

Аня и Боря закоммитили в main каждый своё. git pull --no-rebase (и git старше 2.33.1 без настройки) сделал 282f10f Merge branch 'main' of http://localhost:8765/shop, узел без всякого решения. pull.rebase=true переносит неотправленные коммиты поверх пришедших: b120e8a стал 2db9371, история осталась линейной.

Как работать без сюрпризов

Разделить шаги: git fetch, git log --oneline main..origin/main, а потом merge или rebase. Или pull.ff=only: где можно, pull перематывает, на разошедшихся ветках отказывает уже после fetch: origin/main сдвинут, ветка и файлы нет.

git pull origin main в своей ветке не обновляет main

Команда вливает main сервера в текущую ветку. Стоя на feature с --no-rebase, получишь коммит «Merge branch 'main' of http://localhost:8765/shop into feature», а локальная main останется на месте. Обновить её без переключения можно refspec с правой частью: git fetch origin main:main перемотает локальную ветку, если она не выгружена в рабочую копию и перемотка возможна.

Суть: это три разные ссылки в двух репозиториях. main на сервере двигает push. origin/main лежит в твоём клоне и помнит, где серверная ветка стояла при последнем fetch или push. Локальную main двигают commit, merge и rebase. status сравнивает main с origin/main и в сеть не ходит, поэтому о чужом коммите до fetch не знает.

Как узнать, что на сервере сейчас

До fetch клон Ани и сервер расходились, и это видно, если спросить сервер напрямую:

$ git log --oneline -1 origin/main
af76ac4 (HEAD -> main, origin/main, origin/HEAD) Добавить /health
$ git ls-remote origin main
d088609b9db020b83be01dbf97738cbe9d0bf31c	refs/heads/main

ls-remote только читает список ссылок. git remote show origin тоже ходит на сервер и у отставших веток пишет (local out of date). А git fetch обновляет origin/main, и после него status говорит правду: Your branch is behind 'origin/main' by 1 commit.

Почему в origin/main нельзя коммитить

git switch origin/main отказывается: fatal: a branch is expected, got remote branch 'origin/main'. С --detach HEAD отсоединится, и коммит останется без ветки (глава 1.2), ведь remote-tracking ветку двигает только обмен с сервером.

Суть: в ветке сервера есть коммит, которого нет в твоей. Push проходит, только если новая вершина потомок старой, иначе серверный коммит выпал бы из ветки. Коммит чужой: забери его и встрой через rebase или merge, потом push. Ты сам переписал ветку, которая только твоя: отправляй с --force-with-lease.

Три вида отказа

ОтказЧто это значитЧто делать
(fetch first)вершины сервера в клоне нет, родство не проверитьfetch и посмотреть, что пришло
(non-fast-forward)коммит сервера в клоне есть, но он не предок твоей вершинывстроить или, если переписывал сам, lease
[remote rejected]сервер сам запрещает перезапись: receive.denyNonFastForwards, защищённая веткавстроить; --force не поможет

Сначала посмотреть, чьи коммиты мешают

После git fetch команда git log --oneline main..origin/main покажет коммиты, которых нет у тебя. У Ани там оказался один чужой:

$ git log --oneline main..origin/main
23d68bc (origin/main, origin/HEAD) Поднять таймаут до 5 секунд

Если в списке только прежние версии твоих коммитов, ветку переписывал ты сам (amend, rebase), и встраивать нечего. Чужую работу надо сохранить.

Как отправить

Чужие коммиты встраивают: git pull --rebase переставил b120e8a поверх 23d68bc, и push прошёл перемоткой 23d68bc..2db9371. Конфликт при rebase разбирают по главе 3.2, а если за это время кто-то отправил ещё, fetch и rebase повторяют. --force выбросил бы 23d68bc молча. Свою ветку после amend (глава 2.1) или rebase (глава 3.3) встраивать не нужно, git pull по подсказке задвоит коммиты. Её отправляют через --force-with-lease, если на ней никто не строит (следующий вопрос).

Суть: --force перезаписывает ветку сервера без условий. --force-with-lease перезаписывает, только если ветка на сервере стоит там же, где её помнит remote-tracking ветка, и так спасает от чужого push, которого ты не видел. Фоновый fetch сдвигает remote-tracking ветку без твоего участия, и защита пропадает. --force-if-includes требует, чтобы вершина remote-tracking ветки побывала в reflog твоей ветки.

Что проверяет каждый вариант

КомандаУсловие перезаписиГде подводит
--force, +ветканетвыбрасывает всё, чего нет у тебя
--force-with-leaseсервер = origin/<ветка>после фонового fetch
--force-with-lease=ветка:хешсервер = указанный хешхеш надо запомнить до rebase самому
--force-with-lease --force-if-includesсервер = origin/<ветка>, и её вершина есть в reflog веткипосле cherry-pick вместо rebase

--force в той же команде отменяет lease: git push --force-with-lease --force перезапишет ветку без проверки. Если remote-tracking ветки нет вовсе, а на сервере ветку уже создал коллега, lease откажет с stale info.

Явное ожидание

Форма с хешем не смотрит на origin/retry, поэтому фоновый fetch ей не мешает. Её удобно звать из скриптов: запомнить вершину через git rev-parse до переписывания и передать. Вывод снят раньше, между rebase и последним push Ани:

$ git push --force-with-lease=retry:b6a0d07 --dry-run
To http://localhost:8765/shop.git
 + b6a0d07...7ffc204 retry -> retry (forced update)

С явным хешем --force-if-includes ничего не добавляет, так сказано в git help push.

Как это настроить один раз

git config push.useForceIfIncludes true (git 2.30) добавляет --force-if-includes к каждому --force-with-lease. Найденные после отказа чужие коммиты забирают в ветку до push.

Lease защищает ветку сервера, а не тебя от неудачного rebase

Все эти проверки смотрят, не появилось ли на сервере неожиданного. Если ты сам потерял коммит при rebase, например выбросил его в интерактивном списке, lease и if-includes честно пропустят push, и коммит исчезнет и с сервера. Вернуть его поможет reflog (глава 5.2), пока запись жива.

Что стоит запомнить

  • origin/main - локальная копия известного состояния remote branch.
  • fetch обновляет remote-tracking refs, но не сливает их в текущую ветку.
  • --force-with-lease проверяет ожидаемое старое значение remote ref.

Частые ошибки

  • Up to date относится к последнему известному tracking ref, не к серверу прямо сейчас.
  • Force-with-lease может быть ослаблен явно заданным ожидаемым hash.

Самопроверка

Сначала реши задачу самостоятельно, затем открой ответ и сравни ход рассуждений.

Чтение кода

1. Прочитай ahead и behind

Что означает это состояние и какой обмен нужен, чтобы увидеть новые коммиты коллеги?

## feature...origin/feature [ahead 2, behind 1]
Ответ и разбор

Локальная feature содержит два коммита сверх tracking ref, но отстаёт от известного origin/feature на один commit. Сначала выполни fetch, чтобы обновить сведения о сервере, затем изучи расхождение и выбери merge или rebase. Push напрямую может быть отклонён как non-fast-forward.

Код-ревью

2. Не затирай чужое обновление

Job делает fetch и rebase, затем должен отправить переписанную ветку. После rebase коллега продвинул remote, а IDE запустила фоновый fetch до push. Почему обычный --force-with-lease может затереть новый commit? Зафиксируй ожидаемый OID до фонового fetch и покажи безопасную команду push.

git fetch origin
expected=$(git rev-parse refs/remotes/origin/feature)
git rebase origin/feature
# colleague pushes while the job is running; IDE fetches the new tip
git fetch origin
git push --force-with-lease origin feature
Ответ и разбор

После первого fetch сохрани ожидаемый OID в expected. Если коллега добавил commit после rebase, а фоновый fetch передвинул origin/feature, обычный lease возьмёт уже обновлённый tracking ref за ожидаемое значение и может разрешить push без commit коллеги. Укажи сохранённый OID явно: git push --force-with-lease=refs/heads/feature:$expected origin feature. Если remote уже изменился, push будет отклонён; затем fetch, проверь diff и перебазируй заново.

Применение

3. Настрой tracking branch

Локальная ветка feature создана вручную и не имеет upstream. Как связать её с удалённой веткой при первом push и чем это поможет дальше?

Ответ и разбор

Используй git push -u origin feature. Git запишет upstream для локальной ветки, после чего обычные status, pull и push смогут определить соответствующую remote branch. Перед первым push проверь, что это нужный remote и имя ветки.

Применение

4. Разбери «up to date» при чужом push

Коллега говорит, что запушил новый commit, но status всё ещё показывает up to date. Что произошло и в каком порядке обновить свою ветку?

Ответ и разбор

Локальный remote-tracking ref ещё не обновлялся и отражает прошлый fetch. Выполни git fetch origin, сравни новые refs, затем интегрируй нужную ветку через merge или rebase. Fetch сам не меняет working tree и текущую ветку.