Перейти к содержимому
RootPoligon

Совместная работа

Ситуация

До сих пор Борис «коммитил в ваш репозиторий» понарошку — скрипт подписывал коммиты его именем. На деле у каждого своя копия на своём компьютере. Анна сделала коммит и пишет: «Я добавила субботу в расписание». Борис открывает у себя schedule.html — субботы нет. Кто ошибся?

Главная мысль

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

          git push ─────▶  общий репозиторий  ◀───── git push
Анна                       (remote/site.git)                    Борис
          ◀──── git fetch / git pull ────  ────  git fetch / git pull ────▶
  • git commit сохраняет только у себя. Пока нет git push, коллега коммит не увидит.
  • git fetch скачивает чужие коммиты и обновляет ветки вида origin/main — «что я знаю об общем репозитории». Рабочая папка и ваша ветка main не меняются.
  • git pull --ff-only = fetch + перемотать вашу main вперёд, если вы ничего своего не добавили.
  • git push отправляет ваши коммиты; общий репозиторий отказывает, если в нём есть коммиты, которых нет у вас.

Новые слова

  • Remote (удалённый репозиторий) — адрес общей копии; по умолчанию называется origin.
  • Bare-репозиторий — репозиторий без рабочей папки, только история. Так устроены общие репозитории: в них не работают руками, в них только отправляют и из них забирают.
  • Клон — копия репозитория вместе со связью на origin.
  • origin/main — ветка слежения: последнее известное вам состояние main в общем репозитории.
  • Fast-forward (перемотка) — обновление ветки, когда новые коммиты просто продолжают вашу историю.

Подготовка: общий репозиторий и два участника

Всё живёт в ~/git-course, никакого сервера и аккаунтов. Исходное состояние — ~/git-course/site после главы 5, working tree clean. Скрипт setup.md делает то же, что команды ниже.

mkdir -p ~/git-course/remote
git clone --bare ~/git-course/site ~/git-course/remote/site.git
git clone ~/git-course/remote/site.git ~/git-course/anna-site
git clone ~/git-course/remote/site.git ~/git-course/boris-site
Cloning into bare repository '/root/git-course/remote/site.git'...
done.
Cloning into '/root/git-course/anna-site'...
done.
Cloning into '/root/git-course/boris-site'...
done.

У каждого клона — свой автор, только для этого клона:

cd ~/git-course/anna-site
git config user.name "Анна (учебный)"
git config user.email "anna@example.invalid"
cd ~/git-course/boris-site
git config user.name "Борис (учебный)"
git config user.email "boris@example.invalid"

Дальше вы играете обе роли. Перед каждой командой смотрите, в какой вы папке — от этого зависит, чей это репозиторий. Проверить: pwd.

cd ~/git-course/anna-site
git remote -v
origin	/root/git-course/remote/site.git (fetch)
origin	/root/git-course/remote/site.git (push)

Разбор 1: коммит — это ещё не push

Анна добавляет субботу в расписание (папка anna-site):

cd ~/git-course/anna-site
sed -i 's|<li>Четверг, 19:00 — практика</li>|&\n<li>Суббота, 11:00 — разбор вопросов</li>|' schedule.html
git add schedule.html
git commit -m "Добавить субботний разбор вопросов"
git status
On branch main
Your branch is ahead of 'origin/main' by 1 commit.
  (use "git push" to publish your local commits)

nothing to commit, working tree clean

«Ahead by 1» — коммит есть только у Анны. Отправляем:

git push
To /root/git-course/remote/site.git
   6a2dea6..41cc348  main -> main

Разбор 2: fetch не трогает рабочую папку

Теперь Борис (папка boris-site). Предскажите: появится ли суббота в его schedule.html после git fetch?

cd ~/git-course/boris-site
git fetch
From /root/git-course/remote/site
   6a2dea6..41cc348  main       -> origin/main
grep -c Суббота schedule.html
0

Файл прежний. fetch обновил только origin/main. Зато Git теперь знает, что Борис отстал:

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

Посмотреть, что придёт, до того как забрать:

git log --oneline main..origin/main
git diff --stat main origin/main
41cc348 Добавить субботний разбор вопросов
 schedule.html | 1 +
 1 file changed, 1 insertion(+)

main..origin/main — «коммиты, которые есть в origin/main, но нет в main». Забираем:

git pull --ff-only
Updating 6a2dea6..41cc348
Fast-forward
 schedule.html | 1 +
 1 file changed, 1 insertion(+)

--ff-only — забрать, только если это простая перемотка. Если истории разошлись, Git откажется и ничего не изменит — это и нужно новичку: никаких неожиданных слияний.

Разбор 3: истории разошлись

Оба работают одновременно. Борис дописывает README и коммитит у себя:

cd ~/git-course/boris-site
printf 'Вопросы по запуску — в чат курса.\n' >> README.md
git add README.md
git commit -m "Добавить в README, куда писать вопросы"

Анна в это время меняет заголовок главной и успевает отправить первой:

cd ~/git-course/anna-site
sed -i 's|<h1>Учебный сайт</h1>|<h1>Учебный сайт курса Git</h1>|' index.html
git add index.html
git commit -m "Обновить заголовок главной страницы"
git push

Борис отправляет свой коммит:

cd ~/git-course/boris-site
git push
To /root/git-course/remote/site.git
 ! [rejected]        main -> main (fetch first)
error: failed to push some refs to '/root/git-course/remote/site.git'
hint: Updates were rejected because the remote contains work that you do not
hint: have locally. …

Общий репозиторий отказал: в нём коммит Анны, которого у Бориса нет. Если бы push прошёл, коммит Анны пропал бы из main. Смотрим, что случилось:

git fetch
git status
Your branch and 'origin/main' have diverged,
and have 1 and 1 different commits each, respectively.
git log --oneline --graph --all -4
* 5f84724 Добавить в README, куда писать вопросы
| * 8951228 Обновить заголовок главной страницы
|/
* 41cc348 Добавить субботний разбор вопросов

Перемотка невозможна:

git pull --ff-only
hint: Diverging branches can't be fast-forwarded, you need to either:
…
fatal: Not possible to fast-forward, aborting.

Ничего не изменилось. Объединяем истории знакомым слиянием — origin/main такая же ветка, как любая другая:

git merge --no-edit origin/main
Merge made by the 'ort' strategy.
 index.html | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

Если бы оба поменяли одну строку, здесь был бы конфликт — разрешается так же, как в главе 5. Теперь у Бориса есть всё, что в общем репозитории, плюс его коммит — push пройдёт:

git push
To /root/git-course/remote/site.git
   8951228..952984a  main -> main

Анна забирает: cd ~/git-course/anna-site && git pull --ff-only — у неё простая перемотка до коммита слияния.

Подсказка Git упоминала git rebase — другой способ объединять истории, он переписывает ваши коммиты. В курсе используем слияние: оно ничего не переписывает.

Практика: роли наоборот

Исходное состояние — конец разбора: оба клона и общий репозиторий совпадают (git status в обоих — up to date with 'origin/main'). Если начинаете здесь — setup.md.

  1. Борис добавляет на about.html строку <p>Преподаватели: Анна и Борис.</p> после строки с почтой, коммитит и отправляет.
  2. Анна, не забирая изменений, добавляет в конец списка в schedule.html (перед </ul>) строку <li>Воскресенье — выходной</li> и коммитит.
  3. Анна пытается отправить. Предскажите ответ.
  4. Анна объединяет истории без потери чьей-либо работы и отправляет.
  5. Борис забирает изменения.

Признаки успеха:

  • в обоих клонах git log -1 --format=%h показывает один и тот же хеш, git status — up to date with 'origin/main';
  • в обоих клонах есть и строка про преподавателей, и воскресенье;
  • ни разу не использованы --force и удаление клонов.
Подсказка 1

На шаге 3 ситуация та же, что у Бориса в разборе 3. Что нужно сначала узнать о состоянии общего репозитория?

Подсказка 2

git fetch → git status покажет расхождение. git pull --ff-only откажет — ожидаемо.

Подсказка 3

git merge --no-edit origin/main → git push. У Бориса: git pull --ff-only.

Решение — в answers/06-remotes.md.

Если результат другой

  • Коллега «не видит» ваш коммит. Проверьте git status у себя: ahead of 'origin/main' значит, что вы забыли git push. У коллеги — нужен git fetch или git pull.
  • Коммит попал не в тот клон. Вы были не в той папке. pwd и git config user.name покажут, чей это клон. Исправить так же, как любую ошибку в истории: новым коммитом или отменой (глава 7).
  • Push отказан, и хочется добавить --force. Не нужно. --force перезаписывает ветку в общем репозитории вашей версией — коммиты коллеги, которых у вас нет, исчезнут из main. Правильный путь — fetch, слияние, push.

Проверка понимания

  1. Борис выполнил git fetch и сразу открыл файл. Почему изменений Анны в файле нет и что показывает origin/main?
  2. Чем git pull --ff-only безопаснее простого git pull для новичка?

Ответы — в answers/06-remotes.md.

Дальше: 7. Отмена ошибки.

Подготовка исходного состояния (setup.md)

Блоки работают только в отдельной учебной папке ~/git-course. При неожиданном состоянии они отказывают до записи; ошибка после начала подготовки может оставить часть действий выполненной. Сначала прочитайте сообщение, не удаляйте существующие файлы. Для продолжения после предыдущей главы выбирайте только нужный блок.

Перед главой 6

Создаёт общий репозиторий ~/git-course/remote/site.git и два клона — anna-site, boris-site — из ~/git-course/site. Нужно, чтобы site был на main без незакоммиченных изменений; если site нет, блок сначала создаёт его в состоянии конца главы 5. Папок remote, anna-site, boris-site быть не должно.

bash <<'SETUP'
rp_before6() {
  # --- Проверки: ничего не меняют ---
  fail() { echo "Отказ: $1"; echo "Ничего не изменено."; return 1; }
  course_repo() {
    [ -d "$1/.git" ] || { fail "$1 — нет такого репозитория Git"; return 1; }
    git -C "$1" log --max-parents=0 --format=%s 2>/dev/null | grep -qx 'Первая версия учебного сайта' \
      || { fail "$1 — не учебный репозиторий курса"; return 1; }
  }
  quiet() {  # папка, ожидаемая ветка, разрешённые неотслеживаемые файлы через пробел
    local x f
    [ "$(git -C "$1" symbolic-ref --short -q HEAD)" = "$2" ] || { fail "в $1 текущая ветка не $2"; return 1; }
    for x in MERGE_HEAD REVERT_HEAD CHERRY_PICK_HEAD REBASE_HEAD; do
      git -C "$1" rev-parse -q --verify "$x" >/dev/null && { fail "в $1 не завершена операция Git ($x) — завершите или отмените её"; return 1; }
    done
    for x in rebase-merge rebase-apply; do
      [ -e "$(git -C "$1" rev-parse --git-path "$x")" ] && { fail "в $1 не завершён rebase"; return 1; }
    done
    [ -z "$(git -C "$1" status --porcelain --untracked-files=no)" ] || { fail "в $1 есть незакоммиченные изменения"; return 1; }
    for f in $(git -C "$1" ls-files --others --exclude-standard); do
      case " $3 " in *" $f "*) ;; *) fail "в $1 лишний неотслеживаемый файл $f"; return 1 ;; esac
    done
  }
  last_is() { [ "$(git -C "$1" log -1 --format=%s)" = "$2" ] || { fail "в $1 последний коммит не «$2»"; return 1; }; }
  no_branch() { ! git -C "$1" show-ref --verify -q "refs/heads/$2" || { fail "в $1 уже есть ветка $2"; return 1; }; }
  absent() { [ ! -e "$1" ] || { fail "$1 уже существует — переименуйте её сами, если хотите начать заново"; return 1; }; }
  command -v git >/dev/null || { fail "не найден git"; return 1; }
  absent ~/git-course/remote || return 1
  absent ~/git-course/anna-site || return 1
  absent ~/git-course/boris-site || return 1
  local build_site=no
  if [ -e ~/git-course/site ]; then
    course_repo ~/git-course/site || return 1
    quiet ~/git-course/site main || return 1
  else
    build_site=yes
  fi
  # --- Подготовка: после первой записи при ошибке скрипт останавливается ---
  (
    set -e
if [ "$build_site" = yes ]; then
mkdir -p ~/git-course/site
cd ~/git-course/site
git init -q -b main
git config user.name "Анна (учебный)"
git config user.email "anna@example.invalid"
cat > index.html <<'EOF'
<!doctype html>
<html lang="ru">
<head><meta charset="utf-8"><title>Учебный сайт</title></head>
<body>
<h1>Учебный сайт</h1>
<p>Здесь будет расписание занятий.</p>
</body>
</html>
EOF
cat > site.conf <<'EOF'
# Настройки учебного сайта
PORT=8081
NAME=site
TITLE=Учебный сайт
EOF
cat > README.md <<'EOF'
# Учебный сайт

Запуск для проверки: python3 -m http.server 8081
EOF
git add index.html site.conf README.md
git commit -q -m "Первая версия учебного сайта"
cat > about.html <<'EOF'
<!doctype html>
<html lang="ru">
<head><meta charset="utf-8"><title>О нас</title></head>
<body>
<h1>О нас</h1>
<p>Учебный сайт курса «Git: основы».</p>
</body>
</html>
EOF
git add about.html
git commit -q -m "Добавить страницу «О нас»"
cd ~/git-course/site
sed -i 's/^PORT=8081/PORT=8082/' site.conf
git add site.conf
git commit -q -m "Перенести сайт на порт 8082" -m "Порт 8081 на учебном сервере занят сервисом greeter."
sed -i 's|<p>Здесь будет расписание занятий.</p>|<p>Занятия: вторник и четверг, 19:00.</p>|' index.html
git add index.html
git commit -q -m "Добавить расписание занятий на главную"
sed -i 's/^TITLE=.*/TITLE=Учебный сайт курса Git/' site.conf
git add site.conf
git commit -q -m "Уточнить заголовок сайта"
cd ~/git-course/site
B=(-c user.name="Борис (учебный)" -c user.email="boris@example.invalid")
sed -i 's|<p>Занятия: вторник и четверг, 19:00.</p>|&\n<p><a href="about.html">О нас</a></p>|' index.html
git "${B[@]}" commit -q -am "Добавить ссылку на страницу «О нас»"
cat > README.md <<'EOF'
# Учебный сайт

Запуск для проверки из папки сайта:

    python3 -m http.server 8082

Порт указан в site.conf.
EOF
sed -i 's/^PORT=8082/PORT=80x2/' site.conf
git "${B[@]}" commit -q -am "Обновить описание запуска в README"
sed -i 's|<p>Учебный сайт курса «Git: основы».</p>|&\n<p>Вопросы: через форму на странице курса.</p>|' about.html
git commit -q -am "Добавить контакты на страницу «О нас»"
cd ~/git-course/site
sed -i 's/^PORT=80x2/PORT=8082/' site.conf
git add site.conf
git commit -q -m "Вернуть порт 8082" -m "Опечатка 80x2 попала в коммит «Обновить описание запуска в README»."
cd ~/git-course/site
git switch -q -c schedule-page
cat > schedule.html <<'EOF'
<!doctype html>
<html lang="ru">
<head><meta charset="utf-8"><title>Расписание</title></head>
<body>
<h1>Расписание</h1>
<ul>
<li>Вторник, 19:00 — Git: основы</li>
<li>Четверг, 19:00 — практика</li>
</ul>
</body>
</html>
EOF
git add schedule.html
git commit -q -m "Добавить страницу расписания"
git switch -q main
sed -i '2a Страницы: index.html, about.html.\n' README.md
git add README.md
git commit -q -m "Перечислить страницы в README"
git switch -q schedule-page
cd ~/git-course/site
sed -i 's|<p><a href="about.html">О нас</a></p>|&\n<p><a href="schedule.html">Расписание</a></p>|' index.html
printf '\nРасписание занятий — на странице schedule.html.\n' >> README.md
git add index.html README.md
git commit -q -m "Добавить ссылки на страницу расписания"
git switch -q main
cd ~/git-course/site
git merge -q --no-edit schedule-page
git branch -q -d schedule-page
cd ~/git-course/site
git switch -q -c title-update
sed -i 's/^TITLE=.*/TITLE=Учебный сайт: Git для начинающих/' site.conf
git -c user.name="Борис (учебный)" -c user.email="boris@example.invalid" commit -q -am "Подчеркнуть в заголовке, что курс для начинающих"
git switch -q main
cd ~/git-course/site
sed -i 's/^TITLE=.*/TITLE=Учебный сайт курса Git — осень 2026/' site.conf
git commit -q -am "Указать сезон в заголовке"
if git merge -q --no-edit title-update >/dev/null 2>&1; then
  echo "Ожидался конфликт в site.conf, а слияние прошло без него." >&2; exit 1
fi
if [ "$(git diff --name-only --diff-filter=U)" != "site.conf" ]; then
  echo "Слияние остановилось не из-за ожидаемого конфликта в site.conf." >&2; exit 1
fi
cat > site.conf <<'EOF'
# Настройки учебного сайта
PORT=8082
NAME=site
TITLE=Учебный сайт курса Git для начинающих — осень 2026
EOF
git add site.conf
git commit -q -m "Слить title-update: объединить заголовки"
git branch -q -d title-update
cd ~/git-course/site
git switch -q -c contacts
sed -i 's|<p>Вопросы: через форму на странице курса.</p>|<p>Вопросы: в чате курса, ответ в течение дня.</p>|' about.html
git -c user.name="Борис (учебный)" -c user.email="boris@example.invalid" commit -q -am "Перенести вопросы в чат курса" -m "Форму на странице курса отключили, вопросы теперь принимаются в чате."
git switch -q main
sed -i 's|<p>Вопросы: через форму на странице курса.</p>|<p>Вопросы: через форму на странице курса или по почте course@example.invalid.</p>|' about.html
git commit -q -am "Добавить почту для вопросов"
cd ~/git-course/site
if git merge -q --no-edit contacts >/dev/null 2>&1; then
  echo "Ожидался конфликт в about.html, а слияние прошло без него." >&2; exit 1
fi
if [ "$(git diff --name-only --diff-filter=U)" != "about.html" ]; then
  echo "Слияние остановилось не из-за ожидаемого конфликта в about.html." >&2; exit 1
fi
cat > about.html <<'EOF'
<!doctype html>
<html lang="ru">
<head><meta charset="utf-8"><title>О нас</title></head>
<body>
<h1>О нас</h1>
<p>Учебный сайт курса «Git: основы».</p>
<p>Вопросы: в чате курса, ответ в течение дня.</p>
<p>Почта для вопросов: course@example.invalid.</p>
</body>
</html>
EOF
git add about.html
git commit -q -m "Слить contacts: вопросы в чате, почта отдельной строкой"
git branch -q -d contacts
fi
mkdir -p ~/git-course/remote
git clone -q --bare ~/git-course/site ~/git-course/remote/site.git
git clone -q ~/git-course/remote/site.git ~/git-course/anna-site
git clone -q ~/git-course/remote/site.git ~/git-course/boris-site
git -C ~/git-course/anna-site config user.name "Анна (учебный)"
git -C ~/git-course/anna-site config user.email "anna@example.invalid"
git -C ~/git-course/boris-site config user.name "Борис (учебный)"
git -C ~/git-course/boris-site config user.email "boris@example.invalid"
  )
  if [ $? -ne 0 ]; then
    echo "Ошибка после начала подготовки: часть шагов могла выполниться."
    echo "Посмотрите git status и git log --oneline --all -5. Начать заново: переименуйте ~/git-course"
    echo "(mv ~/git-course ~/git-course-old) и выполните блоки этого файла с начала."
    return 1
  fi
  git -C ~/git-course/anna-site status -sb | head -1
  echo "Готово: общий репозиторий и клоны anna-site, boris-site."
}
rp_before6 </dev/null
SETUP
Разбор и проверка понимания

Лист ученика: 06-remotes.md. Хеши — из проверочного запуска.

Практика

1. Борис (папка boris-site):

cd ~/git-course/boris-site
sed -i 's|<p>Почта для вопросов: course@example.invalid.</p>|&\n<p>Преподаватели: Анна и Борис.</p>|' about.html
git add about.html
git commit -m "Указать преподавателей на странице «О нас»"
git push

2. Анна (папка anna-site):

cd ~/git-course/anna-site
sed -i 's|</ul>|<li>Воскресенье — выходной</li>\n&|' schedule.html
git add schedule.html
git commit -m "Отметить воскресенье выходным"

3. git push отказан: ! [rejected] main -> main (fetch first). В общем репозитории коммит Бориса, которого у Анны нет.

4. Узнать, что изменилось, и объединить:

git fetch
git status
Your branch and 'origin/main' have diverged,
and have 1 and 1 different commits each, respectively.
git merge --no-edit origin/main
git push
Merge made by the 'ort' strategy.
 about.html | 1 +
 1 file changed, 1 insertion(+)
To /root/git-course/remote/site.git
   2b69cc2..540bad7  main -> main

Конфликта нет: Анна и Борис меняли разные файлы.

5. Борис:

cd ~/git-course/boris-site
git pull --ff-only
Updating 2b69cc2..540bad7
Fast-forward
 schedule.html | 1 +
 1 file changed, 1 insertion(+)

В обоих клонах git log -1 --format=%h → 540bad7 (у вас свой хеш, но одинаковый в двух клонах).

Допустимо: git pull --no-rebase --no-edit вместо fetch + merge — то же слияние одной командой. Недопустимо: git push --force — коммит Бориса пропал бы из общего main.

Воспроизведено: Git 2.43.0, Ubuntu 24.04. Простой git pull без ключей в этой ситуации останавливается: fatal: Need to specify how to reconcile divergent branches. — Git просит явно выбрать способ.

Проверка понимания

  1. fetch обновляет только ветку слежения origin/main — сведения об общем репозитории. Ветка main Бориса и его рабочая папка не меняются. origin/main показывает последний известный коммит общего main, в том числе коммит Анны; увидеть его можно git log origin/main или git diff main origin/main.
  2. --ff-only либо перематывает ветку, либо ничего не делает и объясняет почему. Простой git pull при некоторых настройках молча создаёт слияние или переписывает ваши коммиты (rebase) — новичок может не понять, что произошло. С --ff-only расхождение всегда видно, и объединение вы делаете осознанно.

Практика без терминала

Разобрать два варианта в браузере. Выберите наблюдения, проверьте ремонт и сохраните прогресс в аккаунте.

Прочитали и разобрались? Отметьте урок — прогресс сохранится в этом браузере.