Библиотека — Git: модель данных, rebase против merge и спасательные инструменты [X]
Шифр
git-guide
Разделы
tooling
Сверено
31 августа — в этот день утверждения статьи сверялись с первоисточниками, а не правился текст
Объём
32 091 знак · 28 разделов · чтения около 35 минут

Git: модель данных, rebase против merge и спасательные инструменты


Обзор

Git легко использовать по рецептам и трудно использовать осмысленно. Рецепты («если что-то сломалось — git reset --hard, если ветка отстала — git pull») работают ровно до первой нестандартной ситуации, а дальше начинается гадание. Причина в том, что рецепты выучиваются вместо модели, а модель у git на удивление маленькая: объектная база с неизменяемыми снимками, набор указателей на них и три области, между которыми файлы перемещаются. Почти любая команда — это перестановка указателей и перенос содержимого между этими областями.

Эта заметка идёт от модели к практике. Сначала — что такое коммит, ветка и три области; потом главный сюжет, rebase против merge: что именно происходит с графом в каждом случае, почему rebase удобен на личной ветке и разрушителен на общей. Дальше — инструменты, которые спасают, когда что-то уже пошло не так: reflog, bisect, worktree. В конце — работа с конфликтами и разборы ситуаций, в которые попадают все.

Текст опирается на документацию git 2.55 — это последний релиз на август 2026. Там, где поведение или флаг появились в конкретной версии, версия указана: в дистрибутивах живут разные git'ы, и часть приёмов ниже на git из стабильного Debian просто не соберётся.

Модель данных

Коммит — снимок, а не патч

Первое расхождение с интуицией: коммит не хранит изменения. Коммит хранит указатель на дерево (tree) — полный снимок состояния репозитория, — плюс автора, дату, сообщение и указатели на родительские коммиты. Диффы, которые показывает git show и git log -p, вычисляются на лету сравнением двух снимков; в базе их нет. Экономия достигается не хранением патчей, а дедупликацией: одинаковые файлы в тысяче коммитов — это один объект-блоб, на который тысяча ссылок.

Идентификатор коммита — хеш его содержимого, включая хеши родителей. Отсюда всё остальное:

  • Коммит неизменяем. «Изменить коммит» физически невозможно; можно только создать новый объект и переставить на него указатель, а старый оставить сиротой.
  • Хеш зависит от родителей. Меняется база — меняется хеш, даже если содержимое дерева бит в бит то же самое. Именно поэтому rebase создаёт копии коммитов, а не переносит оригиналы.
  • История — направленный ацикличный граф, а не список. Линейность — частный случай, когда у каждого коммита ровно один родитель.

Посмотреть на объекты можно напрямую:

git cat-file -p HEAD          # сам коммит: tree, parent, author, committer
git cat-file -p HEAD^{tree}   # дерево верхнего уровня
git rev-parse HEAD            # полный хеш текущего коммита

Ветка — это указатель

Ветка в git — файл с одной строкой в .git/refs/heads/ (или запись в .git/packed-refs), содержащий хеш коммита. Всё. Создание ветки — запись сорока с небольшим байт; поэтому ветки в git дешёвые, а в системах, где ветка была копией дерева, — нет.

HEAD — символическая ссылка на текущую ветку (ref: refs/heads/main). Когда HEAD указывает не на ветку, а прямо на коммит, это detached HEAD: коммиты создаются, но их никто не удерживает, и после переключения они становятся недостижимыми. Не катастрофа — reflog их помнит, — но и не то состояние, в котором стоит работать долго.

Коммит в этой модели — просто перестановка: создать объект, чей родитель — текущий HEAD, и записать его хеш в файл ветки. Слияние, откат, переключение — вариации того же самого.

до commit:                 после commit:

A ─── B ─── C              A ─── B ─── C ─── D
            ↑                              ↑
          main                           main
          HEAD                           HEAD

Три области

Между «файлом на диске» и «файлом в истории» у git стоит промежуточная ступень, которой нет у большинства VCS, — индекс.

Область Где живёт Что это
worktree файлы в каталоге проекта то, что видит редактор и компилятор
index (staging area) .git/index заготовка следующего коммита: список путей с хешами блобов
HEAD .git/refs/… через .git/HEAD последний коммит текущей ветки

git add копирует состояние из worktree в индекс, git commit превращает индекс в объект-дерево и заворачивает его в коммит. Индекс существует не ради церемонии: он позволяет собрать коммит из части изменений (git add -p), а ещё именно в нём во время конфликтного слияния лежат три версии файла одновременно.

git status — это отчёт о двух диффах сразу: worktree против индекса («Changes not staged») и индекс против HEAD («Changes to be committed»). Явно их можно посмотреть так:

git diff              # worktree ↔ index
git diff --staged     # index ↔ HEAD
git diff HEAD         # worktree ↔ HEAD

reset: soft, mixed, hard

git reset — команда, которая двигает указатель ветки и по дороге может задеть индекс и worktree. Три флага отличаются ровно тем, до какой области доходит действие:

Флаг HEAD index worktree Когда нужен
--soft двигает не трогает не трогает пересобрать последние коммиты, сохранив всё в staged
--mixed (по умолчанию) двигает сбрасывает по новому HEAD не трогает «разкоммитить» и заново выбрать, что войдёт
--hard двигает сбрасывает перезаписывает выбросить всё до указанного состояния

Так, схлопнуть пять последних коммитов в один — это ровно то, что показано в документации git reset:

git reset --soft HEAD~5 && git commit

--hard — единственный из трёх, который уничтожает несохранённую работу: он перезаписывает файлы версией из целевого коммита и удаляет отслеживаемые файлы, которых в нём нет. Незакоммиченные изменения после него не восстановить ничем — они никогда не попадали в объектную базу. А вот сами коммиты, мимо которых уехал указатель, восстанавливаются через reflog (см. ниже): --hard опасен для worktree, но не для истории.

Для точечных операций с 2.23 есть команды с честными именами, и в новом коде лучше писать их: git restore <файл> возвращает файл из индекса, git restore --staged <файл> убирает файл из индекса, git switch <ветка> переключает ветку. git checkout умеет всё это одновременно, и именно поэтому по нему так трудно понять, что произойдёт.

Как адресовать коммиты

Три синтаксиса выглядят похоже и означают совершенно разное — это источник изрядной доли путаницы:

  • HEAD~2 — второй предок по первому родителю. Идёт вглубь истории.
  • HEAD^2 — второй родитель самого HEAD. Осмысленно только для merge-коммитов: ^1 — ветка, в которую вливали, ^2 — которую вливали.
  • HEAD@{2} — не история вообще, а третья сверху запись в reflog: где HEAD был два перемещения назад. Ось времени здесь — не граф, а журнал локальных действий.

Есть и временна́я форма: main@{yesterday}, HEAD@{2.hours.ago}. @{upstream} (или @{u}) — отслеживаемая ветка на удалённом репозитории, @{push} — та, куда уйдёт push, если конфигурация их развела.

merge и rebase

Это ядро статьи. Обе команды решают одну задачу — «в моей ветке должны оказаться чужие изменения», — но делают с графом принципиально разное.

Исходная ситуация всюду ниже одна: от B отошла ветка feature с коммитами X и Y, а main тем временем ушла вперёд на C и D.

A ─── B ─── C ─── D          main
       \
        X ─── Y              feature

merge

git merge создаёт новый коммит с двумя родителями. Содержимое считается трёхсторонним слиянием: общий предок (B), наша сторона (Y), их сторона (D).

git switch feature
git merge main
A ─── B ─── C ─── D          main
       \           \
        X ─── Y ─── M        feature

Существующие коммиты не тронуты: X и Y сохранили свои хеши, а M документирует факт слияния. История стала честной ценой ветвления в графе.

Частный случай — fast-forward. Если текущий коммит является предком целевого (то есть в нашей ветке нет ничего своего), нового коммита не нужно: достаточно переставить указатель.

до:  A ─── B ─── C ─── D        после:  A ─── B ─── C ─── D
           ↑           ↑                                  ↑
         main       origin/main                     main, origin/main

Это поведение по умолчанию, и именно из-за него обычный git pull иногда не создаёт merge-коммит, а иногда создаёт. Управляется явно:

git merge --ff-only main   # слиться только если это перемотка, иначе ошибка
git merge --no-ff feature  # всегда merge-коммит, даже если возможна перемотка
git config merge.ff false  # то же самое как политика репозитория

--no-ff — не украшательство: merge-коммит сохраняет факт, что несколько коммитов были одной задачей, и делает откат целой фичи одной командой (git revert -m 1 <merge>). Обратная сторона — граф, в котором каждая мелочь оставляет пузырь.

squash

git merge --squash feature берёт итоговое содержимое ветки и кладёт его в индекс. Дальше вы делаете один обычный коммит — без второго родителя.

A ─── B ─── C ─── D ─── S     main   (S содержит изменения X и Y)
       \
        X ─── Y               feature  (по-прежнему висит сбоку)

Результат: линейная история, один коммит на задачу, промежуточные шаги не попадают в main. Плата — git не знает, что ветка влита. git branch --merged её не покажет, а повторное слияние той же ветки попытается применить X и Y заново и, скорее всего, даст конфликт. Ветку после squash-слияния положено удалять и не возрождать.

rebase

git rebase main находит коммиты, которые есть в feature и нет в main, и применяет их заново поверх main.

git switch feature
git rebase main
A ─── B ─── C ─── D                  main
                   \
                    X' ─── Y'        feature

X' и Y' — новые объекты: то же содержимое изменений, другой родитель, другой хеш. Оригинальные X и Y никуда не делись из базы, но на них больше никто не ссылается — они станут недостижимыми и в конце концов будут собраны сборщиком мусора.

Отдельно стоит --onto, который умеет переносить не «всё, чего нет в upstream», а произвольный отрезок:

# перенести коммиты из feature, начиная после base, на main
git rebase --onto main base feature

Это рабочая команда для распутывания ветки, случайно отведённой не от того места.

Что выбирать

Разница не в эстетике, а в наборе побочных эффектов.

Rebase уместен, когда ветка личная и ещё не опубликована: он даёт линейную историю, где каждый коммит стоит поверх свежей базы и, теоретически, компилируется. Такую историю приятно читать через git log --oneline и, что важнее, по ней хорошо работает bisect: меньше коммитов, у которых «сломано, потому что база старая». Перед отправкой на ревью привести ветку к текущему main через rebase — нормальная гигиена.

Merge уместен, когда ветка общая, когда важна фактическая история («эти изменения были влиты тогда-то, в таком состоянии») или когда ветка живёт долго. Ещё один практический аргумент: при слиянии конфликт разрешается один раз, при rebase — потенциально на каждом переносимом коммите. Ветку из двадцати коммитов, конфликтующую по одному и тому же файлу, rebase превращает в двадцать одинаковых разрешений (частично лечится rerere, см. ниже).

Золотое правило: не переписывать историю, которую кто-то уже получил. Механика проблемы прямо следует из модели. Коллега забрал X и Y; вы сделали rebase и запушили X' и Y'; его git pull видит расхождение и делает слияние — в результате в общей истории оказываются обе копии каждого коммита, а конфликты приходится разрешать дважды. Хуже: если он успел построить работу поверх X, его коммиты повисают на ветке, которой в upstream больше нет.

Граница «уже получил» проходит не по «запушено», а по «кто-то мог забрать». Ветка, запушенная на свой форк, где кроме вас никого, — всё ещё личная. Ветка в общем репозитории, на которую смотрит CI и коллеги, — общая, даже если ей два часа. Формулировка «не переписывай main» слишком узкая; формулировка «не переписывай ничего опубликованного» слишком широкая для практики pull-request'ов, где force-push в свою ветку — норма.

Есть и обратный случай — когда историю переписали не вы, а upstream. Документация git rebase описывает его отдельно: если после чужого rebase содержимое коммитов не изменилось, достаточно повторного git rebase upstream — уже применённые изменения он пропустит; если изменилось, придётся указать старую точку ветвления руками, например git rebase --onto subsystem subsystem@{1}, где @{1} берётся из reflog.

Настройка pull

git pull — это fetch плюс интеграция, и по умолчанию интеграция — merge. Именно так в истории заводятся коммиты «Merge branch 'main' of github.com:… into main», которые не значат ничего. С git 2.27 при разошедшихся ветках без явной настройки печатается предупреждение и предлагается выбрать политику:

git config --global pull.rebase true      # тянуть с rebase
git config --global pull.ff only          # или вообще запретить неявные слияния

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

Интерактивный rebase

git rebase -i <база> открывает в редакторе список коммитов (todo-лист), где каждая строка — команда. Изменение списка меняет историю: строки можно менять местами, удалять и заменять команду.

Команда Действие
pick взять коммит как есть
reword взять, но открыть редактор сообщения
edit остановиться после применения — можно доправить файлы и --amend
squash влить в предыдущий, объединив сообщения
fixup влить в предыдущий, сообщение выбросить (fixup -c — наоборот, взять его)
drop выбросить коммит
exec выполнить shell-команду после этого коммита
break остановиться в этом месте без применения коммита
update-ref подвинуть ветку, указывающую на это место

exec — недооценённая строчка: git rebase -i --exec 'make test' main прогонит тесты на каждом коммите ветки и остановится на первом сломанном. Это дешёвая проверка того, что линейная история не только красива, но и работоспособна.

Ручное редактирование todo-листа для мелких правок не нужно — есть автоматика. git commit --fixup=<хеш> создаёт коммит с сообщением fixup! …, а git rebase -i --autosquash <база> расставляет такие коммиты по местам и заранее помечает их как fixup:

git commit --fixup=a1b2c3d          # правка к конкретному коммиту
git rebase -i --autosquash main     # todo-лист уже собран правильно
git config --global rebase.autoSquash true   # чтобы не писать флаг каждый раз

Ещё два полезных флага: --autostash прячет незакоммиченные изменения на время rebase и возвращает их после (можно включить через rebase.autoStash), а --update-refs (2.38) при переносе цепочки веток, стоящих друг на друге, двигает и промежуточные ветки, а не только текущую. Без него после rebase нижние ветки стека остаются указывать на старые копии коммитов. Включается насовсем через rebase.updateRefs.

Проверить, что переписывание не изменило содержимого, помогает git range-diff: он сравнивает две последовательности коммитов попарно и показывает диффы диффов.

git range-diff main@{1} main HEAD   # что изменилось между старой и новой версией ветки

Публикация переписанной ветки

После rebase обычный git push откажет: ветка не является потомком того, что лежит на сервере. git push --force сработает, но перезапишет всё, включая коммиты, которые кто-то положил туда пока вы работали.

Правильный инструмент — --force-with-lease: он требует, чтобы текущее значение ветки на сервере совпадало с вашей remote-tracking ссылкой, то есть чтобы вы видели актуальное состояние. У него есть известная дыра, и документация git push предупреждает о ней прямо: защита тривиально обходится, если что-то в фоне обновляет remote-tracking ссылки — cron с git fetch, IDE, файловый watcher. Тогда ваша ссылка уже знает про чужой коммит, проверка проходит, а чужая работа исчезает.

Закрывает дыру --force-if-includes (2.30): он проверяет, что верхушка remote-tracking ссылки действительно достижима из reflog вашей локальной ветки — то есть что чужие изменения не просто были загружены, а были вами интегрированы. Осмысленный вариант — использовать оба:

git push --force-with-lease --force-if-includes

Спасательные инструменты

reflog

Reflog — журнал перемещений ссылок: каждое изменение HEAD и каждой ветки записывается в .git/logs/ вместе со старым и новым значением, автором и причиной («commit», «rebase (finish)», «reset: moving to…»). Это локальный журнал: он не передаётся при push, его нет в свежем клоне и он ничего не знает о том, что делали другие.

Пока коммит есть в reflog, он достижим и не будет удалён сборщиком мусора. Отсюда почти вся способность git к восстановлению.

git reflog                    # журнал HEAD
git reflog show feature       # журнал конкретной ветки
git reflog --date=iso         # с нормальными датами вместо HEAD@{5}

Три типовых спасения:

# 1. Промахнулись с reset --hard
git reset --hard HEAD@{1}     # вернуться туда, где HEAD был до последнего перемещения

# 2. Rebase «съел» коммиты
git reflog                    # найти запись rebase (start) и хеш до неё
git reset --hard <хеш>        # или, если rebase ещё идёт: git rebase --abort

# 3. Удалили ветку, а она была нужна
git reflog                    # найти последний коммит удалённой ветки
git branch feature <хеш>      # воссоздать указатель

Отдельно стоит помнить про ORIG_HEAD: перед потенциально разрушительными операциями (merge, rebase, reset) git сохраняет туда прежнюю позицию, так что git reset --hard ORIG_HEAD — короткий путь для отмены последнего слияния.

Reflog не вечен. По умолчанию git gc удаляет записи старше 90 дней (gc.reflogExpire) и записи, недостижимые из текущей верхушки, старше 30 дней (gc.reflogExpireUnreachable) — вторая цифра как раз про то, что остаётся от commit --amend и rebase. Для важного репозитория пороги можно поднять:

git config gc.reflogExpire "never"
git config gc.reflogExpireUnreachable "never"

Когда reflog не помогает

Reflog фиксирует движение ссылок. Если объект никогда не был верхушкой ветки или HEAD — например, коммит из сброшенного stash или потерянный при обрыве операции, — искать надо в самой базе:

git fsck --unreachable --no-reflogs   # объекты, до которых не дотянуться
git fsck --lost-found                 # разложить их по .git/lost-found/
git show <хеш>                        # посмотреть, что это было

Работает, пока не прошёл git gc.

bisect

git bisect ищет коммит, в котором сломалось, двоичным поиском по истории: за log₂(N) проверок вместо N. На тысяче коммитов это десять шагов.

git bisect start
git bisect bad                 # текущее состояние сломано
git bisect good v1.4.0         # на этом теге всё работало
# git выдаёт очередной коммит; проверяем и отвечаем:
git bisect good                # или git bisect bad
...
git bisect reset               # вернуться к исходному состоянию

Ручной цикл нужен редко — почти всегда есть команда, отличающая хорошее от плохого, и тогда весь поиск делает git bisect run. Правила кодов возврата документация формулирует так: команда должна возвращать 0, если код хорош, и значение от 1 до 127 включительно, кроме 125, если плох. 125 — «этот коммит не тестируется» (эквивалент bisect skip), 128 и выше прекращают поиск.

git bisect start HEAD v1.4.0   # сразу задать плохой и хороший концы
git bisect run ./check.sh

Скрипт обязан лежать вне рабочего дерева или быть устойчивым к тому, что дерево переключается: git на каждом шаге делает checkout, и файл из репозитория подменится или исчезнет вместе со своей версией. /tmp/check.sh надёжнее ./check.sh. Внутри скрипт часто выглядит так:

#!/usr/bin/env bash
make -j"$(nproc)" >/dev/null 2>&1 || exit 125   # не собралось — коммит не тестируем
./run-regression-test || exit 1                 # тест упал — коммит плохой
exit 0

Полезные детали: git bisect terms --old=works --new=broken переименовывает good/bad, когда ищется не поломка, а любое изменение свойства; git bisect skip v2.5..v2.6 исключает диапазон; git bisect log сохраняет ход поиска, а git bisect replay воспроизводит его после исправления ошибочного ответа; --first-parent (2.29) ограничивает поиск основной линией, что резко ускоряет дело в истории, набитой merge-коммитами.

worktree

git worktree даёт несколько рабочих каталогов на один репозиторий. Каждый linked worktree — обычный каталог с файлом .git (не каталогом), внутри которого путь к $GIT_DIR/worktrees/<id>; объектная база и ссылки общие. Отсюда дешевизна: второй worktree не копирует историю.

git worktree add ../proj-hotfix hotfix        # существующая ветка в новый каталог
git worktree add -b review ../proj-review origin/feature   # новая ветка от origin/feature
git worktree add --detach ../proj-test HEAD   # без ветки, для разовой проверки
git worktree list
git worktree remove ../proj-hotfix
git worktree prune                            # почистить записи о вручную удалённых каталогах

Ограничение по конструкции: одну ветку нельзя держать в двух worktree сразу — add откажется, если ветка уже где-то выписана (обходится --force, но это почти всегда ошибка). С 2.42 есть --orphan для worktree на пустой несуществующей ветке.

Практический смысл — замена git stash в сценарии «надо срочно посмотреть другую ветку». Stash — стек анонимных коммитов, из которого легко достать не то и в котором легко похоронить работу на неделю; worktree оставляет текущее состояние нетронутым, включая незакоммиченные правки, открытые файлы в редакторе и запущенную сборку. Отдельный выигрыш — параллельная сборка: два worktree не делят каталог артефактов и не заставляют пересобирать всё при каждом переключении.

Чего ждать не стоит: неотслеживаемые файлы (.env, локальные конфиги, node_modules) в новый worktree не попадают — их надо создавать заново; хуки и конфигурация общие на весь репозиторий; каталог нельзя просто удалить rm -rf, точнее можно, но потом нужен git worktree prune.

Конфликты

Чтение маркеров

Конфликт возникает, когда обе стороны изменили пересекающиеся строки и трёхстороннее слияние не может выбрать. Git оставляет в файле обе версии:

<<<<<<< HEAD
timeout = 30
=======
timeout = 60
>>>>>>> feature

Стиль по умолчанию (merge) показывает только два варианта — вашу сторону и чужую, — и по нему часто непонятно, кто из двоих что менял. Стиль zdiff3 (2.35) добавляет секцию с исходным текстом из общего предка:

git config --global merge.conflictStyle zdiff3

С базой перед глазами вопрос «кто поменял 30 на 60, а кто добавил строку рядом» перестаёт быть гаданием. zdiff3 отличается от более старого diff3 тем, что вытаскивает совпадающие строки за пределы конфликтного блока, делая его короче.

Ловушка rebase. Во время слияния ours — это ваша ветка, theirs — вливаемая. Во время rebase всё наоборот: git по очереди применяет ваши коммиты поверх чужой базы, поэтому ours означает базу (upstream), а theirs — ваш переносимый коммит. Об это спотыкаются регулярно; когда неуверенны, лучше не пользоваться --ours/--theirs, а посмотреть все три версии явно:

git diff --diff-filter=U --name-only   # какие файлы в конфликте
git show :1:path/to/file               # общий предок
git show :2:path/to/file               # ours
git show :3:path/to/file               # theirs
git checkout --conflict=zdiff3 path    # вернуть маркеры, если файл уже испорчен

Числа :1:, :2:, :3: — те самые три стадии, которые индекс держит одновременно, пока конфликт не разрешён.

rerere

rerere (reuse recorded resolution) запоминает, как вы разрешили конкретный конфликт, и применяет то же решение, когда встречает тот же конфликт снова. Записи лежат в .git/rr-cache; включается явно:

git config --global rerere.enabled true
git config --global rerere.autoUpdate true   # сразу класть разрешённое в индекс

Классический сценарий из документации — долгоживущая ветка, которую многократно сливают или переносят: конфликт каждый раз один и тот же, и разрешать его вручную по десять раз бессмысленно. Для интерактивного rebase выигрыш ещё нагляднее: конфликт, повторяющийся на каждом переносимом коммите, разрешается один раз.

Обратная сторона: если решение было ошибочным, rerere будет молча повторять ошибку. Лечится точечно:

git rerere status      # какие пути отслеживаются в текущем конфликте
git rerere diff        # что именно сейчас разрешено
git rerere forget <путь>   # забыть записанное решение для файла

Записи сами протухают при git gc: неразрешённые через 15 дней, разрешённые через 60 (gc.rerereUnresolved, gc.rerereResolved).

Стратегии слияния

Стратегия по умолчанию для слияния двух веток — ort («Ostensibly Recursive's Twin»). Она заменила старый recursive, который был умолчанием с v0.99.9k по v2.33.0; в v2.50.0 имя recursive окончательно превратили в синоним ort. Практически это значит, что на любом современном git отдельно выбирать стратегию не нужно; ort умеет переименования, строит виртуального общего предка при нескольких кандидатах и по умолчанию использует diff-algorithm=histogram.

Опции задаются через -X:

git merge -X ours feature       # конфликтующие куски брать из нашей версии
git merge -X theirs feature     # …из их версии
git merge -X ignore-space-change feature   # игнорировать правки пробелов
git merge -X renormalize feature           # разные правила CRLF/clean-фильтров

Важно не спутать -X ours (опция стратегии: конфликтующие куски берём у себя, неконфликтующие чужие изменения принимаем) и -s ours (стратегия: результат равен нашему дереву, чужие изменения выброшены целиком, но факт слияния зафиксирован). Вторая нужна редко — например, чтобы объявить старую ветку влитой и больше к ней не возвращаться.

Прочие стратегии — нишевые: octopus (умолчание при слиянии более двух веток, отказывается от конфликтных случаев), resolve (простое трёхстороннее без обработки переименований), subtree (когда одно дерево — поддерево другого).

Разборы частых ситуаций

Закоммитил не в ту ветку. Пока не запушено, это перестановка указателей:

git branch feature          # запомнить текущую позицию под новым именем
git reset --hard HEAD~2     # вернуть main туда, где она должна быть
git switch feature          # продолжить работу

Если коммитов много или они вперемешку — переносить точечно через cherry-pick.

Нужно переписать последний коммит. Забыли файл, опечатка в сообщении:

git add забытый-файл
git commit --amend --no-edit    # содержимое поправили, сообщение оставили
git commit --amend              # только сообщение

--amend создаёт новый коммит и переставляет ветку — старый уходит в reflog. Если коммит уже опубликован, действуют те же правила, что и для rebase: в личную ветку — push --force-with-lease --force-if-includes, в общую — лучше отдельный коммит-исправление.

Перенести один коммит из другой ветки.

git cherry-pick a1b2c3d          # один коммит
git cherry-pick a1b2c3d^..e4f5g6 # диапазон включительно с первого
git cherry-pick -x a1b2c3d       # добавить в сообщение строку «(cherry picked from …)»
git cherry-pick -n a1b2c3d       # применить в индекс, не коммитить

-x стоит взять в привычку при переносе исправлений в релизные ветки: без него потом невозможно понять, откуда взялся коммит-двойник. Помнить, что A..B не включает A, — отсюда ^ в примере.

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

git revert a1b2c3d
git revert -m 1 <merge>    # откатить слияние, оставив первого родителя основной линией

Если ветка личная (свой форк, никто не забирал) — можно переписать и запушить с --force-with-lease --force-if-includes.

Отдельный случай — утёкший секрет. revert здесь не решение: объект с ключом остаётся в базе, доступен по хешу, уже разошёлся по клонам, форкам и кешам хостинга. Порядок действий: сначала отозвать и перевыпустить ключ (это обязательная часть, а не запасная), затем при необходимости вычистить историю через git filter-repo и договориться со всеми о переклонировании. Считать секрет скомпрометированным нужно с момента push, а не с момента обнаружения.

Потерялся stash. git stash drop не спрашивает подтверждения, а сброшенный stash не значится ни в одной ветке:

git fsck --unreachable | grep commit | cut -d' ' -f3 | xargs git log --merges --no-walk
git stash apply <хеш>

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

git fetch origin
git rebase --autostash origin/main
git rebase -i --autosquash origin/main
git rebase -i --exec 'make test' origin/main
git push --force-with-lease --force-if-includes

Что стоит настроить один раз

Значения по умолчанию у git местами исторические, и несколько строк в ~/.gitconfig снимают целый класс неприятностей:

[pull]
    ff = only                 ; не создавать неявных merge-коммитов
[rebase]
    autoSquash = true         ; учитывать fixup!/squash! без флага
    autoStash = true          ; не требовать чистого дерева
    updateRefs = true         ; двигать промежуточные ветки стека
[merge]
    conflictStyle = zdiff3    ; показывать общего предка в конфликте
[rerere]
    enabled = true            ; запоминать разрешения конфликтов
[diff]
    algorithm = histogram     ; более осмысленные диффы, чем myers
    colorMoved = zebra        ; отличать перемещённый код от нового
[fetch]
    prune = true              ; убирать ссылки на удалённые ветки

Из перечисленного спорна только pull.ff = only — она превращает привычный git pull в команду, которая иногда отказывается работать. Это и есть цель: расхождение веток должно быть замечено, а не разрешено автоматически способом, который вы не выбирали.

Источники


Связанные заметки

Из библиотеки института уносят только знания. Читателей здесь не считают: посещение не регистрируется, отметок в издании не остаётся, счётчиков нет ни у одного издания фонда.

Издание пополняется и пересверяется. Текст доступен на условиях CC BY-SA 4.0 — унесённое можно передавать дальше на тех же условиях.