← назад к разделу

Первая задача недоделана и ломает сборку, а вторую уже надо начинать, и в одной папке это невозможно: либо доделывать первую, либо жить со сломанной. Ветка снимает этот выбор: работа над задачей идёт в изоляции, можно сколько угодно коммитить и экспериментировать, не трогая рабочую версию, а потом одним действием влить результат обратно. Ветки в Git дешёвые и мгновенные, поэтому весь командный процесс, задачи, ревью, релизы, построен на них.

fix main fast-forward main не двигался, пока вы работали в fix — сливать нечего, закладка просто едет вперёд fix main merge-коммит в main успели влить чужое: Git делает коммит с двумя родителями — история рисует ромб

Одна и та же ветка fix в двух исходах. Сверху main стоял на месте — Git просто переносит закладку main на последний коммит ветки, история остаётся прямой линией. Снизу main ушёл вперёд — появляется коммит с двумя родителями, и в истории видно, где линии разошлись и где сошлись.

Обязательно

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

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

git branch                   # список веток, * — текущая
git switch -c fix-cart-total # создать ветку и переключиться на неё
git switch main              # вернуться на main

Специальная закладка HEAD отмечает, где вы сейчас. Переключение ветки меняет файлы в рабочей директории на снимок той ветки — поэтому Git откажется переключаться, если незакоммиченные правки при этом затрёт (спрячьте их в git stash — карман для незавершённого). А если в целевой ветке файл ровно такой же, Git молча возьмёт вашу правку с собой, и она окажется на другой ветке. Отсюда регулярная паника «мои изменения не в той ветке»: Git их не потерял, он их перенёс. Привычка простая — git status до переключения, а не после.

Сам карман устроен просто: git stash убирает незакоммиченные правки в сторону и оставляет чистую рабочую директорию, git stash pop возвращает их обратно, git stash list показывает, что лежит. Новые, ещё не добавленные файлы обычный stash не трогает, для них git stash -u. Подробности и ловушки кармана разобраны в статье про отмену.

git stash            # спрятать правки
git switch main      # теперь переключение проходит
git switch -         # вернуться на прежнюю ветку
git stash pop        # достать правки обратно

Рабочий ритм: под каждую задачу — своя ветка от свежего main (fix-cart-total, feature-export-csv), в ней коммиты, после вливания ветка удаляется. Ветки — расходный материал, а не долгожители.

Удаляют ветку двумя буквами, и разница между ними важна. git branch -d fix-cart-total удаляет, только если все коммиты ветки уже влиты куда-то ещё; иначе Git откажет с not fully merged, и это защита от потери работы. git branch -D удаляет без проверки. Отказ -d бывает и ложным: если ветку слили через squash, в main уехал один новый коммит, а ваших там формально нет, и Git не видит их влитыми; содержимое при этом на месте, и отвечать надо -D. Правило: -d по умолчанию, -D только когда точно знаете, куда делись коммиты.

Слияние: fast-forward и merge-коммитспросят на собеседовании

Влить ветку в main:

git switch main
git merge fix-cart-total

Возможны два сценария:

  • Fast-forward. Пока вы работали, main не двигался. Тогда сливать нечего — Git просто передвигает закладку main вперёд на ваши коммиты. История остаётся прямой линией.
  • Merge-коммит. Main успел уйти вперёд (коллеги вливали своё). Git создаёт специальный коммит с двумя родителями, объединяющий обе линии. История показывает ромб: разошлись — сошлись. Это и нарисовано на анимации выше.

Оба исхода нормальны. Флаг --no-ff заставляет Git сделать merge-коммит даже там, где хватило бы fast-forward, — так в истории остаётся видимая граница задачи. Граница нужна не для красоты: после fast-forward коммиты задачи неотличимы от соседних, и откатить задачу целиком значит откатывать их по одному; merge-коммит же откатывается одной командой git revert -m 1 <sha>, и вся задача исчезает из main разом. Это и есть единственный практический довод за --no-ff. Какой вариант предпочитать и почему некоторые команды запрещают merge-коммиты — вопрос модели ветвления и rebase; пока достаточно понимать, что происходит.

Конфликты: не авария, а вопросспросят на собеседовании

Конфликт возникает, когда Git не может свести две версии сам. Самый частый случай — обе ветки изменили одни и те же строки по-разному. Git не угадывает, чья правка правильная, — он останавливается и спрашивает вас:

<<<<<<< HEAD
цена = сумма * 0.9;   // ваша версия (текущая ветка)
=======
цена = сумма * 0.85;  // версия вливаемой ветки
>>>>>>> fix-discount

Порядок действий без паники:

  1. git status покажет конфликтующие файлы.
  2. Открыть файл, найти маркеры <<<<<<</>>>>>>>, решить по смыслу: оставить одну версию, другую или написать объединённую. Маркеры удалить.
  3. git add файл — пометить конфликт решённым.
  4. git commit — завершить слияние. Передумали — git merge --abort вернёт всё как было.

Смотреть конфликт можно не только глазами в файле. git diff во время слияния показывает особый формат с двумя колонками знаков: что пришло из вашей ветки, что из вливаемой и что вы уже поправили. Когда ответ известен заранее, файл целиком берут с одной стороны: git checkout --ours путь оставляет вашу версию, --theirs вливаемую (при rebase стороны меняются местами, потому что там «вашей» считается ветка, на которую переносят). А на практике конфликты чаще всего разбирают в трёхпанельном виде IDE или git mergetool: слева ваша версия, справа чужая, посередине результат, и у каждого куска кнопки «взять слева», «взять справа», «оба».

две ветки файл с маркерами правят одну строку моя версия --ours чужая версия --theirs объединённая решить по смыслу

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

Дословное пересечение строк — не единственная форма конфликта. Бывает и так, что одна ветка файл удалила, а другая его правила: Git пишет CONFLICT (modify/delete), а git status помечает файл как deleted by us или deleted by them. Маркеров внутри файла тут нет — решать надо не текст, а судьбу файла: вернуть его (git add) или согласиться с удалением (git rm). Так же всплывают переименования и правки в соседних строках, которые дословно не пересеклись, но попали в один и тот же кусок изменений.

Два взрослых правила. Конфликт решается по смыслу задачи, а не механически: «оставить свою» — не стратегия, вы можете затереть чужой багфикс; сомневаетесь — спросите автора второй правки (blame покажет кого). И профилактика лучше лечения: короткие ветки и регулярное подтягивание main в свою ветку делают конфликты маленькими и редкими. Подтянуть можно двумя способами, и это два разных решения:

git switch main && git pull   # сначала обновить сам main
git switch feature-export
git merge main                # влить свежий main в свою ветку — появится merge-коммит
# либо
git rebase main               # переставить свои коммиты поверх свежего main

merge ничего не переписывает и безопасен всегда; rebase даёт прямую историю, но создаёт ваши коммиты заново — где так можно, а где нельзя, разбирает статья про rebase.

Ветки как навигация по проекту

Ветки — это ещё и навигация. Проверить чужую работу до ревью — переключиться на её ветку. Срочный фикс во время большой задачи — stash, ветка от main, фикс, обратно. Посмотреть, как выглядел код в проде — git switch --detach v2.3.1 по тегу релиза.

Про последнее отдельно. Без --detach Git откажется: он ждёт имя ветки, а тег не ветка. С ним вы окажетесь «вне веток», в состоянии detached HEAD: смотреть можно, а коммитить не стоит, потому что такой коммит не принадлежит ни одной ветке и после переключения его придётся искать через reflog. git switch main возвращает обратно. Умение свободно ходить по веткам превращает Git из «сдать код» в машину времени проекта.

Как называть ветки

fix, fix2, vasya-test, new: через месяц в git branch -a сорок таких имён, и никто не помнит, что в них и можно ли их удалять. Имя ветки читают чаще, чем её код: в списке веток, в pull request, в сообщении CI, поэтому оно должно отвечать на два вопроса: что это и откуда взялось.

Ходовая схема: тип, косая черта, номер задачи и два-три слова о сути, всё строчными и через дефис:

feature/PROJ-142-export-orders-csv     новая возможность
fix/PROJ-171-null-customer-on-refund   исправление в рабочей ветке
hotfix/2.3.1-payment-timeout           срочная правка в проде
chore/upgrade-postgres-driver          обслуживание без изменения поведения
release/2.4                            релизная ветка, если модель их предполагает

Косая черта здесь не украшение: Git хранит ветки как файлы в .git/refs/heads/, и feature/ становится папкой, поэтому git branch --list 'feature/*' показывает только рабочие ветки. Из этого же следует ограничение: если есть ветка feature, ветку feature/login создать нельзя, файл и папка с одним именем не уживаются. Номер задачи в имени связывает ветку с трекером, и многие трекеры по нему сами показывают ветку и pull request в карточке задачи. Суть в два-три слова нужна тому, кто смотрит список веток без трекера под рукой.

Чего избегать: пробелов и кириллицы, которые придётся экранировать в каждой команде; имён, совпадающих с тегами (v2.3.1 как ветка и как тег заставит Git спрашивать, что вы имели в виду); имён длиннее одной строки в списке. Соглашение о типах фиксируют письменно, в CONTRIBUTING.md, и проверяют на сервере: GitLab и GitHub умеют запрещать push ветки, имя которой не подходит под шаблон.

Дополнительно: при первом чтении можно пропустить

Глубже: теги и версиирасширенное

Ветка двигается с каждым коммитом, а версия должна стоять на месте: «v2.3.1» всегда обязана указывать на один и тот же снимок. Для этого и есть тег, указатель, который Git никогда не двигает.

Тегов два вида. Лёгкий (git tag v2.3.1) это просто имя для коммита. Аннотированный (-a) это полноценный объект: у него есть автор, дата и сообщение, его можно подписать, и git describe считает версию от него. Релизы помечают аннотированными.

git tag -a v2.3.1 -m "Release 2.3.1"
git push origin v2.3.1        # теги не уезжают с обычным push
git push --tags               # или все сразу
git checkout v2.3.1           # посмотреть код релиза: detached HEAD, коммитить отсюда нельзя
git tag -d v2.3.1 && git push origin :refs/tags/v2.3.1   # удалить, если поставили не туда

Удалять и переставлять опубликованные теги можно только до того, как ими воспользовались: CI, который собирает образ по тегу, и коллега, который его уже скачал, получат разные снимки под одним именем. Поэтому ошибку в релизе чинят новым тегом v2.3.2, а не переносом старого.

Коротко

  • Ветка — передвижной указатель на коммит; создание мгновенно. Задача = ветка, влили = удалили.
  • HEAD — где вы; переключение меняет файлы; незакоммиченное — в stash.
  • Merge: fast-forward (main не двигался — прямая линия) или merge-коммит с двумя родителями (ромб).
  • Конфликт — вопрос, а не авария: маркеры в файле → решить по смыслу → add → commit; --abort отменяет.
  • Профилактика конфликтов: короткие ветки, регулярно подтягивать main.
  • Версия это аннотированный тег: git tag -a v2.3.1 -m … и отдельный git push origin v2.3.1; опубликованный тег не переставляют.
  • git branch -d удаляет только влитую ветку и ложно отказывает после squash, -D без проверки; stash и stash pop прячут и возвращают правки при переключении.
  • Конфликт смотрят через git diff, берут файл целиком через --ours/--theirs или разбирают в трёх панелях IDE; --no-ff нужен, чтобы откатить задачу одним revert -m 1.

Что почитать дальше