Совместная работа
Ситуация
До сих пор Борис «коммитил в ваш репозиторий» понарошку — скрипт подписывал коммиты его именем. На деле у каждого
своя копия на своём компьютере. Анна сделала коммит и пишет: «Я добавила субботу в расписание». Борис открывает
у себя 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.
- Борис добавляет на
about.htmlстроку<p>Преподаватели: Анна и Борис.</p>после строки с почтой, коммитит и отправляет. - Анна, не забирая изменений, добавляет в конец списка в
schedule.html(перед</ul>) строку<li>Воскресенье — выходной</li>и коммитит. - Анна пытается отправить. Предскажите ответ.
- Анна объединяет истории без потери чьей-либо работы и отправляет.
- Борис забирает изменения.
Признаки успеха:
- в обоих клонах
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.
Проверка понимания
- Борис выполнил
git fetchи сразу открыл файл. Почему изменений Анны в файле нет и что показываетorigin/main? - Чем
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 просит явно выбрать способ.
Проверка понимания
fetchобновляет только ветку слеженияorigin/main— сведения об общем репозитории. ВеткаmainБориса и его рабочая папка не меняются.origin/mainпоказывает последний известный коммит общегоmain, в том числе коммит Анны; увидеть его можноgit log origin/mainилиgit diff main origin/main.--ff-onlyлибо перематывает ветку, либо ничего не делает и объясняет почему. Простойgit pullпри некоторых настройках молча создаёт слияние или переписывает ваши коммиты (rebase) — новичок может не понять, что произошло. С--ff-onlyрасхождение всегда видно, и объединение вы делаете осознанно.
Практика без терминала
Разобрать два варианта в браузере. Выберите наблюдения, проверьте ремонт и сохраните прогресс в аккаунте.
Прочитали и разобрались? Отметьте урок — прогресс сохранится в этом браузере.