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

Отмена ошибки

Ситуация

Три неприятности за утро. Вы случайно стёрли строку в файле. Вы отправили в общий репозиторий коммит, который закрыл запись на занятия, — а Борис уже забрал его к себе. Вы на середине правки, а нужно срочно забрать изменения коллеги. Для каждой есть своя команда — и есть «быстрые» команды, которые решат задачу ценой чужой или вашей работы.

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

Сначала ответьте, где ошибка (карта из пяти мест — в карте курса):

Где ошибкаКомандаЧто теряется
Правка в рабочей папке, не нужнаgit restore файлэта незакоммиченная правка
Лишнее в индексеgit restore --staged файлничего: правка остаётся в папке
Коммит уже отправленgit revert коммитничего: новый коммит отменяет старый
Недоделанная работа мешаетgit stash push -m "…", потом git stash popничего, если не удалять запись

Чем дальше изменение ушло по пути «папка → индекс → коммит → общий репозиторий», тем важнее отменять его новым действием, а не стиранием истории.

Новые слова

  • git restore — вернуть файл в папке к версии из индекса (а с --staged — убрать файл из индекса).
  • git revert — создать новый коммит, который делает обратное указанному коммиту.
  • git stash — временно убрать незакоммиченные правки в отдельное хранилище и вернуть их позже.

Разбор

Исходное состояние — конец главы 6: клоны anna-site и boris-site совпадают с общим репозиторием, git status в обоих чистый. Если начинаете здесь — setup.md. Всё ниже — в папке Анны, если не сказано иное. Хеши — пример.

1. Испорченная правка в папке: git restore

cd ~/git-course/anna-site
sed -i '/<li>Четверг/d' schedule.html
git diff
 <li>Вторник, 19:00 — Git: основы</li>
-<li>Четверг, 19:00 — практика</li>
 <li>Суббота, 11:00 — разбор вопросов</li>

Строка удалена случайно. Вернём файл к последней сохранённой версии:

git restore schedule.html
git status --short
grep -c Четверг schedule.html
1

git status --short пуст, строка на месте. Осторожно: git restore без --staged выбрасывает незакоммиченную правку файла — Git её нигде не хранил, вернуть нельзя. Перед ним всегда смотрите git diff и убеждайтесь, что выбрасываете именно ошибку.

2. Лишнее в индексе: git restore --staged

Анна дописала README и заодно сохранила черновик заметок, а потом отметила всё сразу:

printf 'черновик: идеи для страницы контактов\n' > notes.txt
printf 'Сайт обновляется по вторникам.\n' >> README.md
git add .
git status --short
M  README.md
A  notes.txt

Черновику в коммите не место. Убираем его из индекса — файл остаётся в папке:

git restore --staged notes.txt
git status --short
M  README.md
?? notes.txt
git commit -m "Указать день обновления в README"

3. Отправленный коммит: git revert

Анна по ошибке закрыла запись и успела отправить коммит:

sed -i 's|<p>Занятия: вторник и четверг, 19:00.</p>|&\n<p>Запись на занятия закрыта.</p>|' index.html
git add index.html
git commit -m "Закрыть запись на занятия"
git push
git log --oneline -3
04a1de6 Закрыть запись на занятия
f14e3ce Указать день обновления в README
540bad7 Merge remote-tracking branch 'origin/main'

Коммит уже в общем репозитории, Борис мог его забрать. Стирать его из истории нельзя: у Бориса он останется, и истории разойдутся. Отменяем новым коммитом. Отменяемый коммит — последний, поэтому вместо хеша (у вас он другой) можно написать HEAD:

git revert --no-edit HEAD
[main 37279b7] Revert "Закрыть запись на занятия"
 Date: Fri Oct 16 14:38:20 2026 +0300
 1 file changed, 1 deletion(-)
git show --stat HEAD
…
    Revert "Закрыть запись на занятия"

    This reverts commit 04a1de617dbc7cb9b939544fe5b8d98aa5770242.

 index.html | 1 -

revert отменяет весь коммит. Если в нём были и нужные правки (как в главе 3), лучше исправить одну строку новым коммитом. Отправляем отмену:

git push

У Бориса (cd ~/git-course/boris-site && git pull --ff-only) — простая перемотка: в истории и ошибка, и отмена, а файл как до ошибки.

4. Недоделанная работа мешает: git stash

Борис отправил правку расписания (это делает скрипт setup.md). Анна в это время начала добавлять пятницу в тот же файл и не готова коммитить:

cd ~/git-course/anna-site
sed -i 's|</ul>|<li>Пятница, 18:00 — консультация</li>\n&|' schedule.html
git pull --ff-only
error: Your local changes to the following files would be overwritten by merge:
	schedule.html
Please commit your changes or stash them before you merge.
Aborting

Git отказался, чтобы не затереть правку. Уберём её на время, с подписью:

git stash push -m "Черновик: консультация в пятницу"
git stash list
Saved working directory and index state On main: Черновик: консультация в пятницу
stash@{0}: On main: Черновик: консультация в пятницу

Папка чистая (кроме неотслеживаемого notes.txt — stash по умолчанию его не трогает). Забираем и возвращаем черновик:

git pull --ff-only
git stash pop
Auto-merging schedule.html
…
	modified:   schedule.html
…
Dropped refs/stash@{0} (6e0900d…)

Правки Бориса и черновик Анны соединились, запись в хранилище удалена — она больше не нужна. Если бы pop наткнулся на конфликт, Git оставил бы запись (The stash entry is kept in case you need it again.) — тогда разрешите конфликт как в главе 5 и только потом удалите запись.

Быстрые команды, которые уничтожают работу

Эти команды встречаются в советах из интернета. Здесь — что они делают с теми же ситуациями.

  • git reset --hard — возвращает ветку, индекс и папку к коммиту. Все незакоммиченные правки отслеживаемых файлов исчезают без возврата; если указать старый коммит, более новые коммиты ветки пропадают из её истории. В ситуации 1 хватило git restore одного файла; в ситуации 3 reset убрал бы коммит только у Анны, а у Бориса он остался бы.
  • git clean -fd — удаляет все неотслеживаемые файлы и папки. Черновик notes.txt исчез бы навсегда: Git его никогда не хранил. Нужно убрать один файл — удалите именно его, посмотрев git status.
  • git push --force — перезаписывает ветку в общем репозитории вашей версией. Коммиты коллег, которых у вас нет, пропадают из общей истории. В ситуации 3 правильно — revert и обычный push.
  • git stash drop — удаляет запись из хранилища. Если правки не были возвращены (pop с конфликтом, забытая запись), они практически потеряны. Удаляйте запись, только убедившись, что её содержимое в папке или в коммите: git stash show -p stash@{0}.

Практика: три ошибки сразу

Скрипт setup.md создаёт ситуацию в клоне Анны:

  • Анна отправила коммит «Перейти на порт 9000» — ошибка, сайт должен остаться на 8082;
  • после этого Борис отправил правку расписания — Анна её ещё не забрала;
  • в папке Анны две незакоммиченные правки: нужный черновик в schedule.html (понедельник — выходной) и случайно удалённая строка с почтой в about.html.

Задача (в папке ~/git-course/anna-site): вернуть строку с почтой, сохранить черновик, забрать правку Бориса, отменить порт 9000 для всех участников.

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

  • в anna-site и boris-site grep PORT site.conf → PORT=8082, последний коммит — Revert "Перейти на порт 9000";
  • коммит Бориса «Указать аудиторию для четверга» есть в истории обоих клонов;
  • в anna-site черновик про понедельник — в папке ( M schedule.html), git stash list пуст;
  • grep -c 'Почта для вопросов' about.html → 1.
Подсказка 1

Разберите каждую правку по таблице «где ошибка». Порядок важен: что мешает забрать правку Бориса?

Подсказка 2

about.html — git restore; черновик — git stash push -m …; затем git pull --ff-only и git stash pop.

Подсказка 3

Хеш ошибочного коммита найдёт git log -S 9000 --oneline. Дальше git revert --no-edit <хеш>, git push, и git pull --ff-only у Бориса.

Решение — в answers/07-undo.md.

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

  • git revert пишет Your local changes to the following files would be overwritten by merge и fatal: revert failed. Незакоммиченные правки в том же файле, что меняет отменяемый коммит. Уберите их в stash, выполните revert, верните pop.
  • После stash pop конфликт. Запись осталась в git stash list. Разрешите конфликт в файле, git add файл, проверьте результат и только потом git stash drop.
  • Открылся редактор с сообщением Revert "…". Вы не указали --no-edit. Сохраните и закройте редактор.

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

  1. Коммит с ошибкой есть только у вас, вы его ещё не отправляли. Подходит ли git revert? Есть ли риск у других способов?
  2. Почему перед git restore файл нужно смотреть git diff, а перед git restore --staged файл — не обязательно?

Ответы — в answers/07-undo.md.

Дальше: 8. Итоговая работа и проверка.

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

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

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

Конец главы 6: оба клона совпадают с общим репозиторием. Выполняется после блока «Перед главой 6». Если в клонах нет своих неотправленных коммитов, блок сам заберёт в них последние изменения (git pull --ff-only).

bash <<'SETUP'
rp_before7() {
  # --- Проверки: ничего не меняют ---
  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; }; }
  clone_ok() {  # клон курса: связан с общим репозиторием, все его коммиты уже там
    course_repo "$1" || return 1
    [ "$(git -C "$1" remote get-url origin 2>/dev/null)" = "$HOME/git-course/remote/site.git" ] \
      || { fail "$1 не связан с ~/git-course/remote/site.git"; return 1; }
    git -C ~/git-course/remote/site.git merge-base --is-ancestor "$(git -C "$1" rev-parse main)" main 2>/dev/null \
      || { fail "в $1 есть коммиты, которых нет в общем репозитории — отправьте или разберите их"; return 1; }
  }
  command -v git >/dev/null || { fail "не найден git"; return 1; }
  local c
  for c in anna-site boris-site; do  # сначала проверить оба клона
    clone_ok ~/git-course/$c || return 1
    quiet ~/git-course/$c main "" || return 1
  done
  # --- Подготовка: после первой записи при ошибке скрипт останавливается ---
  (
    set -e
cd ~/git-course/anna-site
git pull -q --ff-only
cd ~/git-course/boris-site
git pull -q --ff-only
  )
  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
  echo "anna-site: $(git -C ~/git-course/anna-site log -1 --format=%h), boris-site: $(git -C ~/git-course/boris-site log -1 --format=%h)"
  echo "Готово: оба клона совпадают с общим репозиторием."
}
rp_before7 </dev/null
SETUP

Оба хеша в итоговой строке должны совпасть.

Стеш в разборе главы 7

Борис отправляет правку расписания — нужно для пункта 4 разбора главы 7.

bash <<'SETUP'
rp_stash7() {
  # --- Проверки: ничего не меняют ---
  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; }; }
  clone_ok() {  # клон курса: связан с общим репозиторием, все его коммиты уже там
    course_repo "$1" || return 1
    [ "$(git -C "$1" remote get-url origin 2>/dev/null)" = "$HOME/git-course/remote/site.git" ] \
      || { fail "$1 не связан с ~/git-course/remote/site.git"; return 1; }
    git -C ~/git-course/remote/site.git merge-base --is-ancestor "$(git -C "$1" rev-parse main)" main 2>/dev/null \
      || { fail "в $1 есть коммиты, которых нет в общем репозитории — отправьте или разберите их"; return 1; }
  }
  command -v git >/dev/null || { fail "не найден git"; return 1; }
  clone_ok ~/git-course/boris-site || return 1
  quiet ~/git-course/boris-site main || return 1
  # --- Подготовка: после первой записи при ошибке скрипт останавливается ---
  (
    set -e
cd ~/git-course/boris-site
git pull -q --ff-only
sed -i 's|<li>Вторник, 19:00 — Git: основы</li>|<li>Вторник, 19:00 — Git: основы (ауд. 12)</li>|' schedule.html
git commit -q -am "Указать аудиторию для вторника"
git push -q
  )
  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/boris-site log --oneline -1
  echo "Готово: Борис отправил правку расписания."
}
rp_stash7 </dev/null
SETUP

Практика главы 7

Если в папке Анны остался черновик про пятницу из разбора, сначала сохраните его: cd ~/git-course/anna-site && git add schedule.html && git commit -m "Добавить консультацию в пятницу" && git push. Неотслеживаемый notes.txt в клоне Анны можно оставить — это единственный разрешённый лишний файл.

bash <<'SETUP'
rp_practice7() {
  # --- Проверки: ничего не меняют ---
  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; }; }
  clone_ok() {  # клон курса: связан с общим репозиторием, все его коммиты уже там
    course_repo "$1" || return 1
    [ "$(git -C "$1" remote get-url origin 2>/dev/null)" = "$HOME/git-course/remote/site.git" ] \
      || { fail "$1 не связан с ~/git-course/remote/site.git"; return 1; }
    git -C ~/git-course/remote/site.git merge-base --is-ancestor "$(git -C "$1" rev-parse main)" main 2>/dev/null \
      || { fail "в $1 есть коммиты, которых нет в общем репозитории — отправьте или разберите их"; return 1; }
  }
  command -v git >/dev/null || { fail "не найден git"; return 1; }
  clone_ok ~/git-course/anna-site || return 1
  quiet ~/git-course/anna-site main "notes.txt" || return 1
  clone_ok ~/git-course/boris-site || return 1
  quiet ~/git-course/boris-site main || return 1
  # --- Подготовка: после первой записи при ошибке скрипт останавливается ---
  (
    set -e
cd ~/git-course/anna-site && git pull -q --ff-only
cd ~/git-course/boris-site && git pull -q --ff-only
cd ~/git-course/anna-site
sed -i 's/^PORT=8082/PORT=9000/' site.conf
git commit -q -am "Перейти на порт 9000"
git push -q
cd ~/git-course/boris-site
git pull -q --ff-only
sed -i 's|<li>Четверг, 19:00 — практика</li>|<li>Четверг, 19:00 — практика (ауд. 14)</li>|' schedule.html
git commit -q -am "Указать аудиторию для четверга"
git push -q
cd ~/git-course/anna-site
sed -i 's|<ul>|&\n<li>Понедельник — выходной</li>|' schedule.html
sed -i '/Почта для вопросов/d' about.html
git status --short
  )
  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
  echo "Готово: ситуация практики главы 7 создана."
}
rp_practice7 </dev/null
SETUP

В конце блок покажет M about.html, M schedule.html (и ?? notes.txt, если он остался).

Разбор и проверка понимания

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

Практика

Папка ~/git-course/anna-site. Сначала разберём, где что:

git status --short
git diff about.html
 M about.html
 M schedule.html
…
-<p>Почта для вопросов: course@example.invalid.</p>

1. Случайная правка в папке — вернуть файл:

git restore about.html

2. Нужный черновик мешает забрать изменения — убрать на время:

git stash push -m "Черновик: понедельник"
git pull --ff-only
git stash pop
Updating 7550449..7c40f94
Fast-forward
 schedule.html | 2 +-
…
Dropped refs/stash@{0} (…)

Правка Бориса (аудитория 14) и черновик Анны — в разных строках, pop соединил их без конфликта.

3. Отправленный ошибочный коммит — отменить новым коммитом:

git log -S 9000 --oneline
7550449 Перейти на порт 9000
git revert --no-edit 7550449   # подставьте свой хеш из git log -S
git push
grep PORT site.conf
[main aac7e0a] Revert "Перейти на порт 9000"
…
To /root/git-course/remote/site.git
   7c40f94..aac7e0a  main -> main
PORT=8082

revert прошёл, хотя в папке есть черновик: отменяемый коммит меняет site.conf, а черновик — schedule.html.

4. У Бориса:

cd ~/git-course/boris-site
git pull --ff-only
grep PORT site.conf
PORT=8082

Итог в anna-site: git status --short → M schedule.html (черновик), git stash list пуст.

Допустимо: закоммитить черновик вместо stash, если он готов, — но тогда это осознанный коммит с сообщением, а не «временный». Недопустимо: git reset --hard (уничтожит черновик), git push --force (затрёт коммит Бориса), git checkout -- ./git restore . без разбора (выбросит и черновик вместе с ошибкой).

Воспроизведено: Git 2.43.0, Ubuntu 24.04.

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

  1. Подходит: revert безопасен всегда, просто в истории останутся ошибка и её отмена. Пока коммит не отправлен, можно и «переделать» его (git commit --amend, git reset без --hard) — это вне курса, и риск у них есть: неверный ключ reset выбросит правки. Главное правило — переписывать можно только то, что никто, кроме вас, ещё не получил.
  2. git restore файл выбрасывает незакоммиченную правку безвозвратно — нужно убедиться, что выбрасываете ошибку. git restore --staged файл только убирает файл из индекса; правка остаётся в папке, ничего не теряется.

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

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

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