Git и командная работа Удалённые репозитории и протокол: refspec, fetch, push, транспорт
0%

Удалённые репозитории и протокол: refspec, fetch, push, транспорт

Удалённые репозитории и протокол: refspec, fetch, push, транспорт

Одиннадцать статей трека мы работали внутри одного репозитория. Между тем половина ежедневных страданий с Git происходит ровно на границе: push отклонён с непонятной формулировкой, pull притащил чужой merge-коммит, ветка на сервере есть, а локально её «нет», после --force у коллеги пропала работа, а в CI клон висит четыре минуты.

Причина одна и та же: remote воспринимают как «сервер», как некую сущность с состоянием, куда «отправляют код». На самом деле никакого специального протокола общения репозитория с «центром» не существует, и центра тоже не существует. Есть две строчки в .git/config, есть грамматика отображения имён ссылок (refspec) и есть очень простой протокол «покажи, что у тебя есть — вот чего мне не хватает — на, забирай пачку объектов».

Эта статья выводит всю сетевую часть Git из этих трёх вещей. Она опирается на объектную модель и достижимость из «Внутри Git», на три пространства имён ссылок из обзора трека и на приёмы восстановления после чужого force-push из «Восстановления». Механику фильтров --filter=blob:none и shallow-клонов не повторяем — это «Большие репозитории».

Remote — это две строчки в конфиге

Начнём с фактов из файловой системы:

$ git remote add upstream https://github.com/acme/shop.git
$ git config --get-regexp '^remote\.|^branch\.'
remote.origin.url git@github.com:my-fork/shop.git
remote.origin.fetch +refs/heads/*:refs/remotes/origin/*
remote.upstream.url https://github.com/acme/shop.git
remote.upstream.fetch +refs/heads/*:refs/remotes/upstream/*
branch.main.remote origin
branch.main.merge refs/heads/main

Это весь «удалённый репозиторий»: имя, URL и правило отображения ссылок. Никакого состояния, никакой сессии, никакого «подключения». git remote add дописывает две строки, git remote remove их удаляет (плюс подчищает refs/remotes/<имя>/).

Отсюда сразу два следствия, снимающих типовые вопросы. Первое: «origin» — не ключевое слово, а просто имя по умолчанию, которое ставит git clone;ремоутов может быть сколько угодно, и они равноправны. Второе: branch.main.remote + branch.main.merge — это и есть «upstream-ветка», связь, из-за которой git status умеет писать «ahead 2, behind 1».

Refspec: грамматика, из которой выводится всё

Refspec выглядит так: [+]<src>:<dst>. Слева — имя ссылки на источнике, справа — куда её записать на приёмнике. Кто источник, а кто приёмник, зависит от направления: при fetch источник — сервер, при push — вы.

Элемент Значение
+ разрешить обновление, не являющееся fast-forward (перезаписать)
* подстановка: должна быть ровно один раз с каждой стороны
пустой src (:refs/heads/tmp) при pushудалить ссылку на той стороне
^refs/heads/wip/* негативный refspec: исключить из выборки (Git 2.29+)

Стандартный fetch-refspec +refs/heads/*:refs/remotes/origin/* читается буквально: «все ветки сервера положить в моё локальное зеркало под refs/remotes/origin/, перезаписывая даже при расхождении». Префикс + тут не опасен: refs/remotes/* — не ваши ветки, это кэш чужого состояния, и он обязан отражать сервер как есть.

Практические применения, которые обычно ищут по StackOverflow:

# скачивать только одну ветку — экономия на репозитории с тысячей веток
git config remote.origin.fetch '+refs/heads/main:refs/remotes/origin/main'

# добавить второе правило: скачивать ещё и pull request'ы GitHub, только для чтения
git config --add remote.origin.fetch '+refs/pull/*/head:refs/remotes/origin/pr/*'
git fetch origin && git switch --detach origin/pr/812

# не тянуть служебные ветки автоматики
git config --add remote.origin.fetch '^refs/heads/dependabot/*'

# запушить локальную ветку под другим именем
git push origin feature/pricing:refs/heads/review/pricing

# удалить ветку на сервере: пустой src
git push origin :refs/heads/old-experiment
git push origin --delete old-experiment          # то же самое человеческим языком

# отправить текущий HEAD, не создавая локальную ветку
git push origin HEAD:refs/heads/hotfix-2026-07-16

Почему git push origin main иногда «делает не то». Потому что это сокращение: если refspec не указан полностью, Git достраивает dst по правилам push.default.

Значение push.default Поведение
simple (по умолчанию с Git 2.0) пушить текущую ветку в upstream-ветку того же имени; иначе отказ
upstream пушить в настроенный upstream, даже если имя отличается
current пушить в ветку того же имени на указанном remote; удобно для треугольных потоков
matching пушить все одноимённые ветки — поведение Git 1.x, источник классических аварий
nothing требовать явный refspec всегда

Для форк-ориентированной работы (тяну из upstream, пушу в origin) есть отдельная настройка: remote.pushDefault = origin плюс push.default = current. После этого @{upstream} указывает на апстрим, а @{push} — на вашу ветку в форке, и git log @{push}..HEAD показывает «что ещё не отправлено».

Что fetch делает и чего он не делает

git fetch не трогает ни одну вашу ветку и ни один файл в рабочем каталоге. Он только скачивает объекты и обновляет refs/remotes/<remote>/* плюс пишет FETCH_HEAD. Это операция без побочных эффектов: её можно запускать когда угодно и сколько угодно, в том числе по таймеру.

$ git fetch origin
remote: Enumerating objects: 42, done.
remote: Counting objects: 100% (42/42), done.
remote: Compressing objects: 100% (18/18), done.
Unpacking objects: 100% (31/31), 12.42 KiB | 708.00 KiB/s, done.
From github.com:acme/shop
   2d368f9..bc8d834  main         -> origin/main
 * [new branch]      feature/tax  -> origin/feature/tax
 + 9f3a2c1...4d1e8b7 release/2.4  -> origin/release/2.4  (forced update)

Три вида записей в выводе читаются по модели: .. — fast-forward, ветка сервера ушла вперёд; [new branch] — появилось новое имя; ... со знаком +на сервере переписали историю, и ваше зеркало перезаписано принудительно (спасибо префиксу + в refspec). Последняя строка — единственное штатное уведомление о чужом force-push, и её стоит замечать.

Опасна не первая, а вторая половина git pull: это fetch плюс merge (или rebase при pull.rebase=true). Именно она создаёт неожиданные merge-коммиты и втягивает вас в конфликты в неподходящий момент. Разумный дефолт для команды:

git config --global pull.ff only        # pull, который не создаёт коммитов молча
git config --global fetch.prune true    # удалять зеркала веток, удалённых на сервере
git config --global fetch.pruneTags false

fetch.prune решает вечную проблему «в git branch -r сорок веток, которых на сервере давно нет»: без него удалённые на сервере ветки остаются в вашем refs/remotes/ навсегда. Разово то же самое делает git remote prune origin, а git remote show origin показывает список устаревших зеркал и то, какая ветка настроена как HEAD:

$ git remote show origin
* remote origin
  Fetch URL: git@github.com:acme/shop.git
  HEAD branch: main
  Remote branches:
    main                    tracked
    feature/tax             tracked
    refs/remotes/origin/old stale (use 'git remote prune' to remove)
  Local branch configured for 'git pull':
    main merges with remote main

Теги приезжают отдельно. Refspec +refs/heads/*:... их не покрывает; Git отдельно «доследует» (auto-follow) аннотированные теги, указывающие внутрь скачанной истории. Управляется это --tags, --no-tags и remote.<имя>.tagOpt. В обратную сторону симметрии нет: git push теги не отправляет — нужен git push origin v2.4.0, git push --tags или, что аккуратнее всего, git push --follow-tags (отправит только аннотированные теги, достижимые из отправляемых коммитов). Классическая авария релиза «тег есть у меня локально, а в CI его нет» — ровно про это.

Что происходит на проводе

Транспортов несколько, но протокол один. Единица обмена — pkt-line: четыре шестнадцатеричных символа длины, затем данные. Длина считается вместе с префиксом, поэтому минимальный содержательный пакет — 0005 плюс один байт.

Формат pkt-line: длина в четырёх hex-символах, полезная нагрузка и служебные пакеты

Пакеты 0000, 0001 и 0002 данных не несут и работают как знаки препинания: «конец секции», «граница возможностей и аргументов», «конец ответа».

Исторически было два поколения протокола. v0 начинал разговор с того, что сервер выкладывал весь список ссылок — на репозитории с сотней тысяч тегов и веток это мегабайты трафика ещё до того, как клиент сказал, что ему нужно. v2 (по умолчанию начиная с Git 2.26) сделал диалог командным: клиент запрашивает ls-refs с фильтром по префиксу и получает только нужное. Отсюда правило: если у вас «медленный git fetch» на большом репозитории, первым делом проверьте версию протокола.

$ git config --global protocol.version 2
$ GIT_TRACE_PACKET=1 git fetch origin 2>&1 | head -20
packet:  upload-pack> version 2
packet:  upload-pack> agent=git/2.43.0
packet:  upload-pack> ls-refs=unborn
packet:  upload-pack> fetch=shallow wait-for-done
packet:  upload-pack> object-format=sha1
packet:  upload-pack> 0000
packet:        fetch> command=ls-refs
packet:        fetch> agent=git/2.43.0
packet:        fetch> 0001
packet:        fetch> peel
packet:        fetch> symrefs
packet:        fetch> ref-prefix refs/heads/
packet:        fetch> 0000
packet:  upload-pack< bc8d834... refs/heads/main

Это не отладочная экзотика, а самый быстрый способ понять, что сломалось: видно, какие возможности объявила каждая сторона, где оборвался диалог и дошло ли дело до передачи pack-файла вообще.

Дальше начинается согласование (negotiation): клиент говорит, какие вершины ему нужны (want <oid>), сервер спрашивает, что у клиента уже есть, клиент перечисляет свои вершины (have <oid>), обе стороны сходятся на общей базе, и сервер шлёт pack только с недостающими объектами. Пакет при этом тонкий (thin pack): объекты в нём могут быть дельтами к объектам, которых в пакете нет, — предполагается, что у клиента они уже есть; на приёме git index-pack --fix-thin достраивает пакет до самодостаточного.

Для push роли зеркальны: на той стороне работает git-receive-pack, клиент шлёт тройки «старое значение — новое значение — имя ссылки» и pack с объектами, сервер проверяет права, запускает хуки pre-receive и update (см. «Хуки и автоматизацию») и отвечает построчно ok refs/heads/main или ng refs/heads/main non-fast-forward.

Транспорты

Транспорт Пример URL Аутентификация Когда выбирать
SSH git@github.com:acme/shop.git ключи, ssh-agent основной для людей: нет ввода пароля, работает за большинством прокси
Smart HTTPS https://github.com/acme/shop.git токен/пароль через credential helper CI, корпоративные сети, где открыт только 443
Dumb HTTP обычный веб-сервер над .git базовая HTTP легаси; сервер ничего не считает, клиент тянет файлы как есть
git:// git://host/repo.git нет никакой не использовать: нет шифрования и подлинности, GitHub отключил его в 2022
Локальный путь /srv/git/shop.git права файловой системы клон на одной машине; по умолчанию использует жёсткие ссылки на объекты
file:// file:///srv/git/shop.git права файловой системы тот же путь, но через полноценный протокол — полезно для воспроизведения багов

Разница между /srv/git/repo и file:///srv/git/repo неочевидна и иногда важна: первый вариант — оптимизация, при которой Git просто делает жёсткие ссылки на файлы объектов (мгновенно, но клон «связан» с оригиналом на уровне inode; отключается --no-hardlinks), второй честно поднимает upload-pack и гоняет pkt-line.

Smart HTTPS — это два запроса: GET /info/refs?service=git-upload-pack для знакомства и POST /git-upload-pack для собственно обмена. Отсюда, кстати, растёт частая корпоративная проблема: прокси, который буферизует или обрезает POST-тело, ломает именно вторую фазу — клон «зависает» после «Enumerating objects».

Аутентификация: ключи, токены и где их не хранить

# SSH: проверить, что ключ вообще принимается — до всяких git-команд
$ ssh -T git@github.com
Hi ada! You've successfully authenticated, but GitHub does not provide shell access.

# отдельный ключ для конкретного репозитория, без правки ~/.ssh/config
$ git config core.sshCommand 'ssh -i ~/.ssh/id_deploy_shop -o IdentitiesOnly=yes'

# посмотреть, каким ключом Git реально ходит
$ GIT_SSH_COMMAND='ssh -v' git fetch origin 2>&1 | grep 'Offering\|Authenticated'

Для HTTPS пароли давно заменены токенами, и хранит их credential helper — отдельная программа, к которой Git обращается по простому текстовому протоколу.

Helper Где лежит секрет Оценка
store ~/.git-credentials, открытым текстом только для одноразовых контейнеров
cache в памяти демона, --timeout (по умолчанию 15 минут) приемлемо на личной машине
osxkeychain, libsecret, wincred системное хранилище ОС правильный выбор для рабочей станции
Git Credential Manager системное хранилище + OAuth-поток корпоративные среды с SSO
git config --global credential.helper libsecret
git config --global credential.https://git.corp.example.com.username ada

В CI хранить нечего — токен приходит из секретов пайплайна, и задача только в том, чтобы он не утёк в логи:

# правильно: заголовок, значение подставляется из секрета окружения
git -c http.extraHeader="Authorization: Basic $(printf 'x-access-token:%s' "$TOKEN" | base64)" \
    clone --filter=blob:none https://git.corp.example.com/acme/shop.git

# неправильно: токен окажется в URL, а URL — в логах, в .git/config и в выводе ошибок
git clone https://x-access-token:$TOKEN@git.corp.example.com/acme/shop.git

Отдельный полезный механизм — переписывание URL. Он позволяет держать в коде и подмодулях один канонический адрес, а ходить по нему разными способами:

# у себя работаем по SSH, хотя везде записан HTTPS
git config --global url."git@github.com:".insteadOf "https://github.com/"

# в CI наоборот: только HTTPS
git config --global url."https://github.com/".insteadOf "git@github.com:"

# читаем с зеркала, пишем в оригинал
git config --global url."https://mirror.corp/gh/".insteadOf "https://github.com/"
git config --global url."git@github.com:".pushInsteadOf "https://github.com/"

Это же — рабочий ответ на боль с подмодулями, у которых в .gitmodules зашит неудобный протокол (см. «Большие репозитории»). Про хранение секретов вообще — «Управление секретами».

Push отклонён: разбор по причинам

Сервер соблюдает один инвариант: ссылка двигается только вперёд по графу, то есть старое значение обязано быть предком нового. Всё остальное — либо отказ, либо явное принуждение.

Реальные тексты, которые стоит уметь опознавать:

$ git push origin main
 ! [rejected]        main -> main (fetch first)
error: failed to push some refs to 'github.com:acme/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.

$ git push --force-with-lease origin main
 ! [rejected]        main -> main (stale info)

Второй отказ — самый ценный в этом списке. --force-with-lease передаёт серверу ожидаемое старое значение ссылки: «перезапиши main, но только если он сейчас равен тому, что записано в моём refs/remotes/origin/main». Сервер сравнивает и отказывает, если кто-то успел запушить. Это ровно та проверка, которой нет у голого --force.

На такой картинке git push --force уничтожит «чужой B» без единого вопроса; --force-with-lease откажет, потому что реальное значение ссылки на сервере не совпадёт с ожидаемым.

Ловушка, из-за которой lease часто бесполезен. Гарантия строится на вашем зеркале refs/remotes/origin/main, а его обновляет любой git fetch — в том числе автоматический fetch, который ваша IDE делает раз в минуту в фоне. Тогда зеркало уже знает про «чужой B», проверка проходит, и коммит коллеги всё равно теряется. Лечится второй проверкой, появившейся в Git 2.30:

git config --global push.useForceIfIncludes true

--force-if-includes дополнительно требует, чтобы коммит, на который указывает зеркало, был достижим из вашего локального reflog ветки — то есть чтобы вы действительно видели чужую работу и построили свою поверх неё, а не просто скачали её фоном. Практический вывод: --force-with-lease + push.useForceIfIncludes — рабочий дефолт; голый --force не нужен почти никогда. Что делать, если force-push всё-таки затёр чужое, — в «Восстановлении».

Серверная сторона без хостинга

Полноценный Git-сервер — это каталог с bare-репозиторием и доступ по SSH. Никакого демона, базы данных и веб-интерфейса для работы Git не требуется.

# на сервере
$ sudo -u git git init --bare /srv/git/shop.git
$ sudo -u git git -C /srv/git/shop.git symbolic-ref HEAD refs/heads/main
$ sudo -u git git -C /srv/git/shop.git config receive.denyNonFastForwards true
$ sudo -u git git -C /srv/git/shop.git config receive.denyDeletes true
$ sudo -u git git -C /srv/git/shop.git config transfer.fsckObjects true

# на клиенте
$ git remote add origin git@server:/srv/git/shop.git
$ git push -u origin main

Ключевые настройки приёмной стороны:

Настройка Что делает
receive.denyNonFastForwards запрещает любой force-push на уровне сервера
receive.denyDeletes запрещает удаление веток и тегов
receive.denyCurrentBranch по умолчанию refuse: нельзя пушить в ветку, которая на сервере выкачана в рабочий каталог; updateInstead разрешает и обновляет файлы
transfer.fsckObjects проверять целостность и корректность приходящих объектов, а не верить на слово
receive.maxInputSize ограничение на размер приходящего pack — защита от случайного гигабайта
uploadpack.allowFilter разрешить partial clone (--filter=blob:none) клиентам

receive.denyCurrentBranch объясняет знаменитую ошибку «refusing to update checked out branch». Причина логичная: Git обновил бы ссылку, но не файлы в рабочем каталоге сервера, и репозиторий оказался бы в состоянии «всё закоммичено и одновременно всё изменено». Правильный ответ — держать на сервере bare-репозиторий; вариант updateInstead существует для деплой-сценариев вроде «пушим прямо на прод-машину».

Два вида «полной копии» легко перепутать:

git clone --bare  origin.git shop.git    # без рабочего каталога, refspec НЕ настроен
git clone --mirror origin.git shop.git   # bare + refspec +refs/*:refs/* + remote.origin.mirror

--mirror копирует всё пространство имён — ветки, теги, remote-tracking-ссылки, notes — и git remote update в нём приводит копию к состоянию оригинала один в один, включая удаления. Это правильный инструмент для миграции между хостингами и для резервной копии; --bare — для «пустого сервера, куда будут пушить».

Когда сети нет: bundle

git bundle упаковывает произвольный диапазон истории в один файл, который сам по себе является валидным remote. Его можно клонировать, из него можно тянуть — без сети вообще.

# полная копия всей истории одним файлом
$ git bundle create /media/usb/shop-full.bundle --all

# инкремент: только то, что появилось после известной обеим сторонам точки
$ git bundle create /media/usb/shop-delta.bundle v2.3.0..main

# на другой стороне: проверить, что бандл применим к тому репозиторию
$ git bundle verify /media/usb/shop-delta.bundle
The bundle contains this ref:
bc8d834... refs/heads/main
The bundle requires this ref:
2d368f9... 
The bundle uses this hash algorithm: sha1

# использовать как обычный remote
$ git clone /media/usb/shop-full.bundle shop
$ git -C shop pull /media/usb/shop-delta.bundle main
$ git ls-remote /media/usb/shop-full.bundle

Три сценария, где это выигрывает у архива с .git: перенос через физически изолированный контур, где нет сети; «первичная заливка» огромного репозитория в CI (бандл лежит в объектном хранилище, воркер качает его одним потоком, потом добирает свежие коммиты обычным fetch); проверяемый холодный бэкап — git bundle verify честно скажет, что файл цел и применим, чего tar про .git не скажет никогда. Тот же механизм в промышленном виде — bundle URI (transfer.bundleURI), когда сервер отдаёт клиенту ссылку на заранее собранный бандл, чтобы разгрузить upload-pack.

Диагностика

Порядок действий при любой сетевой проблеме — от дешёвого к дорогому:

git ls-remote origin                       # 1. вижу ли я сервер и его ссылки вообще
ssh -T git@github.com                      # 2. проходит ли аутентификация (для SSH)
GIT_TRACE=1 git fetch origin               # 3. какие процессы Git запускает и с чем
GIT_TRACE_PACKET=1 git fetch origin        # 4. диалог протокола построчно
GIT_TRACE_CURL=1 git fetch origin          # 5. HTTP-обмен (заголовки авторизации маскируются)
GIT_SSH_COMMAND='ssh -vvv' git fetch       # 6. подробности SSH: какой ключ, какой хост
GIT_TRACE2_PERF=1 git fetch origin         # 7. где именно уходит время

git ls-remote стоит запомнить отдельно: он не создаёт ничего локально и отвечает на вопрос «проблема в доступе или в моём репозитории» за одну секунду.

Пара распространённых заблуждений:

  • «Увеличьте http.postBuffer до 500 МБ». Совет кочует по форумам и почти всегда бесполезен. Буфер влияет лишь на то, использовать ли chunked transfer encoding; поднимать его осмысленно только против древних или несовместимых прокси, и он съедает соответствующий объём памяти. Настоящие причины «RPC failed» — лимиты прокси на размер тела, обрыв по таймауту и падение сервера на большом pack.
  • «--depth=1 ускорит клон в CI». Иногда да, но серверу shallow-клон обходится дороже обычного, а сборка теряет merge-base, describe и bisect. Для CI почти всегда лучше --filter=blob:none — разбор компромиссов в «Больших репозиториях».

Типичные ошибки

  • Считать git push --force нормальной командой. Он не задаёт вопросов и не проверяет ничего. Дефолт — --force-with-lease вместе с push.useForceIfIncludes.
  • Полагаться на --force-with-lease при включённом фоновом fetch в IDE. Гарантия тихо перестаёт работать; см. разбор выше.
  • Не включать fetch.prune. Через полгода локальный список удалённых веток не имеет отношения к реальности, а git branch -r бесполезен.
  • Забывать, что теги не пушатся сами. Релиз собран, тег есть локально, в CI его нет — и пайплайн собирает не то. Лечится --follow-tags.
  • Класть токен в URL remote. Он осядет в .git/config, в логах CI и в тексте любой ошибки. Только credential helper или http.extraHeader.
  • Пушить в не-bare репозиторий на сервере. Ошибка denyCurrentBranch — не каприз, а защита от рассинхронизации ссылки и рабочего каталога.
  • Путать --bare и --mirror при миграции. --bare не перенесёт все ссылки, и часть тегов и веток тихо потеряется.
  • Лечить сетевые симптомы http.postBuffer и --depth=1. Сначала GIT_TRACE_PACKET, потом выводы.

Мини-итог

  • Remote — это имя, URL и refspec в .git/config. Ни состояния, ни сессии, ни «главного» репозитория в Git не существует.
  • Refspec [+]<src>:<dst> объясняет всё: +refs/heads/*:refs/remotes/origin/* — обычный fetch, пустой src — удаление ветки на сервере, ^ — исключение (Git 2.29+).
  • fetch безопасен всегда: он не трогает ваши ветки и файлы, только refs/remotes/* и FETCH_HEAD. Опасна вторая половина pull.
  • Строка вида + 9f3a2c1...4d1e8b7 release/2.4 (forced update) в выводе fetch — единственное штатное уведомление о чужом force-push.
  • Теги приезжают auto-follow’ом, но не уезжают сами: нужен git push --follow-tags.
  • Протокол — это pkt-line: четыре hex-символа длины плюс данные, 0000/0001/0002 как знаки препинания. v2 (по умолчанию с Git 2.26) не выкладывает все ссылки сразу и потому радикально быстрее на больших репозиториях.
  • Обмен состоит из знакомства (ls-refs), согласования want/have и передачи thin pack, который достраивается на приёме через index-pack --fix-thin.
  • Сервер двигает ссылку только вперёд по графу. fetch first — у вас нет чужих коммитов; stale info — сработала защита lease.
  • --force-with-lease сверяет ожидаемое старое значение ссылки; фоновый fetch обесценивает эту проверку, поэтому нужен ещё push.useForceIfIncludes (Git 2.30+).
  • Настоящий Git-сервер — это git init --bare плюс SSH; receive.denyNonFastForwards, receive.denyDeletes и transfer.fsckObjects дают больше гарантий, чем любые договорённости.
  • git bundle — самодостаточный однофайловый remote: перенос без сети, заливка кэша в CI и единственный проверяемый формат холодного бэкапа.
  • Диагностика идёт от git ls-remote к GIT_TRACE_PACKET, а не от советов с форумов про http.postBuffer.

Источники

Что дальше

Трек закончен. Мы прошли путь от «Git — это key-value хранилище с DAG поверх» до расследований по истории и разбора протокола на уровне пакетов, и на каждом шаге ответ находился в модели данных, а не в списке флагов. Если сейчас перечитать карту трека, большинство пунктов должно читаться как очевидные следствия, а не как набор команд для заучивания. Куда двигаться дальше — зависит от того, что стало узким местом:

Общая карта портала с порядком изучения треков — Роадмап.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов