Еще немножко про git

Перевели крупнейший наш проект с CVS на git. В связи с этим возникло много непониманий и вопросов о том, как теперь делать правильно. Никита разродился большим описанием предлагаемого workflow:

[В данном письме используется ascii-art, так что включите фиксированный шрифт]

Прежде чем расписывать сценарии, хотелось бы ещё раз обратить ваше
внимание на базовые свойства GIT:

*) Коммит ВСЕГДА делается только в ЛОКАЛЬНЫЙ репозиторий.

*) За исключением несущественных сейчас особых случаев, коммит ВСЕГДА делается
на ЛОКАЛЬНУЮ ветку. Напоминаю, что ветка - это фактически указатель на коммит;
в результате создания нового коммита текущая ветка начинает указывать на вновь
созданный коммит.

Это означает, что в git разработка делается на ветке ВСЕГДА. "Разработка на
trunk" (где под trunk понимается ветка в удалённом репозитории, например в
находящемся на git.lvk.cs.msu.su) физически невозможна.

Поэтому, правильно говорить не о "разработке на trunk" или на ещё какой-то
ветке в удалённом репозитории, а о синхронизации локальных веток с удалёнными.

Тут уже можно ставить вопросы:
- с какой удалённой веткой вы синхронизируетесь (с master или с какой-то ещё),
- когда вы это делаете.


То же самое, другими словами.

В CVS/SVN вы работали с двумя сущностями:

[рабочий каталог] < -> [общий репозиторий]

Всякое изменение в рабочем каталоге сохранить некуда, кроме как в общий
репозиторий. Если изменился общий репозиторий, то сохранить изменение вообще
нельзя, предварительно не выполнив операцию update (и не выполнив слияние при
необходимости).
Вопросы Макса явно предполагают именно эту схему.

Но в GIT схема другая, там три сущности:

[рабочий каталог] < -> [локальный репозиторий] < -> [удалённые репозитории]

Операции "сохранить текущую работу в виде коммита" и "синхронизироваться с
внешними изменениями" оказываются полностью независимыми друг от друга.

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

Независимо от этого, любую из локальных веток можно различным образом
синхронизировать с какой-нибудь веткой в каком-нибудь удалённом репозитории.


А теперь попробую ответить на вопросы.

> Прежде всего, "основной сценарий" разработки некоторого модуля


Отталкиваться лучше не от начала дня, а от начала решения некоторой задачи (не
важно, 5 минут это займёт или месяц; неважно, сколько задач решается в
параллель).

Решение задачи лучше начинать с чистого рабочего каталога (то есть, в рабочем
каталоге не должно быть изменений, не сохранённых в локальном репозитории).
Проверить это можно командой 'git status'.

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

Тогда есть варианты:

- не браться за новую задачу, не закончив старую
  - это не всегда возможно

- "cvs-стиль 1": смешать в рабочем каталоге решение старой задачи с решением
  новой
  - иногда прокатывает, но часто приводит к проблемам - например,
    если решение разных задач потребует модифицировать один и тот же файл

- "cvs-стиль 2": слить неготовое решение в "общий репозиторий"
  - и тогда остальные разработчики будут это расхлёбывать

- "cvs-стиль 3": заводить отдельный рабочий каталог на каждую задачу
  - неэффективно (долгая сборка, перерасход дискового пространства, куча
    мусора в каталогах со временем и т.д.)

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

Для реализации этого "правильного решения" git предоставляет целых два средства.

- Для простых случаев - git stash. Эта команда позволяет сохранить все
  изменения рабочего каталога в некоторый буфер, и откатить рабочий каталог к
  последнему коммиту. Позже изменения можно вернуть из буфера командой
  git stash apply. Буфера можно именовать, их может быть сколько угодно.

- Для более сложных случаев - например, когда ваше частичное решение уже
  содержит несколько коммитов в локальный репозиторий - лучше использовать
  обычный git commit. При этом изменения окажутся сохранены в ещё один коммит
  в локальный репозиторий. Что совершенно не страшно - позже, когда вы
  вернётесь к прерыванной задаче, этот локальный коммит можно будет заменить
  или удалить.

Итак, получили чистый рабочий каталог.

Дальше надо решить, на какой локальной ветке будет решение новой задачи:
- если вы не использовали git commit для сохранения частичных решений (то есть,
  если ваша текущая ветка не содержит "неопубликованных" коммитов, то можно её
  и использовать,
- если же у вас на текущей ветке есть "неопубликованные" коммиты, то придётся
  завести новую локальную ветку,
- а можно и всегда заводить новую локальную ветку, именуя её по решаемой
  задаче - так порядку будет больше.

Также, надо решить, с какой базы будет начинаться решение новой задачи.
Во многих случаях базой логично сделать текущее положение ветки master
репозитория на git.lvk.cs.msu.su - но, например, если делается фикс для
кода, поставленного заказчику, то база будет соответствующей.

Новая ветка, указывающая на текущее положение ветки master репозитория на
git.lvk.cs.msu.su, создаётся например так:

git fetch origin master
git branch new_task origin/master
git checkout new_task

Первая команда загружает в ваш локальный репозиторий возможные изменения ветки
master удалённого репозитория origin.
Вторая - создаёт локальную ветку new_task, указывающую на верхний коммит ветки
origin/master.
Третья - делает ветку new_task текущей (в частности, извлекает соответствующее
этой ветке дерево файлов из локального репозитория в рабочий каталог).

Ветку репозитория master можно не указывать. Если же базой для вашей ветки 
является не master, а например, devel, то первые две команды 
будут выглядить так:
git fetch origin devel
git branch fix_task origin/devel

Если же:

- у вас "однозадачный режим", то есть вся работа ведётся на локальной
  ветке master,
- это именно та локальная ветка master, которая образовалась в результате
  первоначально операции git clone, создавшей ваш репозиторий (то есть, вы её
  явно не переконфигурировали, не удаляли/пересоздавали и т.п.),

то достаточно [при чистом рабочем каталоге!] выполнить команду

git pull

При этом результат будет тот же самый - в ваш локальный репозиторий будут
загружены возможные изменения ветки master удалённого репозитория origin,
локальная ветка master станет указывать на текущее значение origin/master, и
рабочий каталог будет содержать текущее дерево файлов, соответствующее этой
ветке.


После этого можно работать - решать задачу.

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

Результатом решения задачи должен стать один или несколько коммитов на
локальной ветке, соответствующей этой задаче (или на локальной ветке master -
см. выше).

Теперь эти коммиты надо "опубликовать" - например, на ветку master репозитоиия
на git.lvk.cs.msu.su.

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

Публикация осуществляется командой

git push

(более полный синтаксис - git push remote_name local_branch:remote_branch - но
в описываемом сценарии все параметры должны быть подставлены по умолчанию)

При этом на уданённый репозиторий из локального репозитория (не из рабочего
каталога, а именно из локального репозитория) будут загружены объекты,
появившиеся в результате вашей работы. А затем будет сделана попытка
продвинуть удалённую ветку.

Вот тут возможны два варианта.

Если с момента начала решения вами вашей задачи удалённую ветку никто не
продвинул, то операция закончится успехом.

А если нет, то будет сообщение об ошибке "not fast-forward".

НИ В КОЕМ СЛУЧАЕ нельзя в ответ передавать команде git push ключ -f (force) -
ЭТО ПРИВЕДЁТ К ПОТЕРЕ КОММИТОВ между вашей базой и текущим состоянием ветки.
Физически данные не потеряются, но без специальных мер они не будут
видны.
Видимо, правильно будет запретить force на важных ветках на уровне pre-receive
hook - подумаем над этим.

Правильных решений при ошибке "not fast-forward" в ответ на git push может
быть два - rebase и merge.
В большинстве случаев из них нужно выбрать rebase.
Смысл этой операции вот в чём.

Когда вы начали решение задачи, состояние дерева коммитов было таким:

... - (*) - (M)

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

... - (*) - (M)
              \
              (1) - ... - (K)

Внизу - несколько коммитов, составляющих решение вашей задачи.

А в результате действий кого-то другого оно стало таким:

... - (*) - (M) - ... - (N)
              \
              (1) - ... - (K)

Эти схемы изображают именно деревья коммитов. Одно и то же дерево коммитов
хранится в разных репозиториях.

Операция rebase - это ЗАМЕНА созданной вами последовательности коммитов на
ДРУГУЮ, в которой патчи будут по возможности те же, а родительские отношения -
другие. Цель - получить вот это:

... - (*) - (M) - ... - (N)
                          \
                          (1) - ... - (K)

Если это проделать, то последующая операция git push закончится без ошибки, и
приведёт к

... - (*) - (M) - ... - (N) - (1) - ... - (K)

Теперь - как проделать операцию rebase.
В этой операции задействован рабочий каталог. Соответвенно, начинать её надо с
"чистого" состояния рабочего каталога. Впрочем, нет причин начинать публикацию
не с чистого.

Операция выполняется так (если удалённая ветка - именно origin/master):

git fetch origin
git rebase origin/master

Первая команда загрузит в локальный репозиторий новые коммиты, появившиеся в
ветке master репозитория origin. Эта операция абсолютно недеструктивна - она
только помещает новые объекты в локальный репозиторий и продвигает сохранённую
в локальном репозитории ссылку origin/master.

Вторая команда пытается выполнить требуемую замену последовательности
коммитов. При этом делается следующее:
- создаётся временная локальная ветка, указывающая на точку N,
- эта временная ветка извлекается в рабочий каталог,
- на временную ветку последовательно применяются (cherry-pick) коммиты из
  заменяемой последовательности
- после этого ваша локальная ветка (как ссылка) подменяется вновь созданной.

В процессе rebase-а возможны конфликты. В этом случае их надо разрешить
вручную, после чего продолжить rebase командой git rebase --continue.
Если на каком-то этапе разрешить конфликт не получается, можно отменить всю
операцию rebase при помощи git rebase --abort. Краткая подсказка выдаётся
вместе с сообщением о конфликте. Полностью все возможности можно посмотреть
через git help rebase.


Вот в общем-то и всё.

Чтобы вернуться к старой задаче, которую вы прервали, начав эту, достаточно
выполнить операцию git checkout соответствующей локальной ветки (если другая
задача на ветке), или же операции git pull; git stash apply (если другая
задача на stash)


Если кому-то ну очень не хочется работать в терминах задач, как я тут
расписал, а хочется "тесного взаимодействия" с удалённым репозиторием, то
этого можно достичь так:

- ограничиться работой только на локальной ветке master,
- перед каждым commit делать pull и разрешать конфликты [это фактически
  извращённая форма rebase, когда локальных коммитов нет, а рабочий каталог
  грязный],
- после каждого commit делать push

Но при этом теряется как минимум описанная выше поддержка многозадачности, а
также возможность переписывать историю (так как переписывать опубликованную
историю нежелательно).

Честно говоря, я не вижу рациональных причин так поступать.

> Команд update/revert нет.
> checkout делает явно что-то "другое"

См. 'git-svn crash course, http://git.or.cz/course/svn.html'
Это простая сводная таблица команд svn и их аналогов в git.

> 1. Захожу в каталог своего модуля (.../src/XXX/)
>     (в случае, если ночевал в чужом каталоге).
> 2. Делаю команду svn status или svn status -rHEAD.
>     (этот шаг делается если "давно не был" или забыл, есть
>      ли несохраненные изменения).
> 2'. Если trunc для моего модуля "ушёл", смотрю svn log/svn diff.
> 3. Делаю svn up. В случае конфликтов разрешаю их.
>
> ...
>
> Например, что (и когда) я должен делать _вместо_ шагов 1.-3. ?

Мне кажется, расписанный выше workflow содержит исчерпывающий ответ на этот
вопрос? Если нет, то что именно непонятно?

> Что я должен делать вместо revert ?

git checkout path/to/file

> Например, git status/diff/commit без аргументов "действуют" не на
> текущий модуль, а на весь Проект :(. В некоторых случаях флаг --force
> существенно изменяет логику операциию.

Команда status описывает состояние всего рабочего каталога - такова
её семантика. Это не должно как-либо мешать в "ориентированном на задачи"
workflow.

Команда commit без аргументов создаёт коммит из текущего содержимого git
index, куда изменения заносятся командой git add.
Команда commit -a перед выполнением коммита заносит в git index все изменения
рабочего каталога. Использовать её надо с осторожностью.

Я лично всегда перед коммитом - даже временным/локальным - делаю git status,
чтобы понять, что произойдёт. Если это не соответствует моим пожеланиям, я
выполняю команды, которые тот же git status показывавет в качестве подсказки.

git diff по умолчанию действительно действует на весь рабочий каталог. Но
опять же, в "ориентированном на задачи" workflow он покажет тебе только
изменения, сделанные в рамках текущей задачи.

Флаг --force использовать не надо, если ты только не представляешь очень
хорошо, что делаешь.


> Добавлю, что _после_ расписывания сценария работы с транком следует
> расписать:
> 1. Сценарий разработки функциональности "в отдельной ветке"
> (в терминах CVS) с последующей заливкой на транк.

При использовании "ориентированного на задачи" workflow разницы практически не
будет. Пока ветка локальная - так вообще не будет.

Если по любой причине необходимо или хочется публиковать промежуточные
результаты в удалённом репозитории, можно создать там ветку

git fetch origin
git branch my_cool_branch origin/master
git push origin my_cool_branch:my_cool_branch
git branch -D my_cool_branch

после чего использовать в описанном workflow ветку origin/my_cool_branch в
качестве базы.

Дальнейшее слияние может быть через rebase или через merge.


Rebase делается примерно так (предполагаю, что в локальном репозитории ветка
my_cool_branch уже есть):

git fetch origin
git checkout my_cool_branch
git rebase origin/master

В результате в локальном репозитории ветка my_cool_branch будет заменена
на новую, построенную относительно текущего положения origin/master

Далее возможны два варианта:

git push origin my_cool_branch:master

Это разместит всю историю my_cool_branch поверх текущего положения master, 
причём указатель ветки master в репозитории origin будет переставлен на
последний коммит my_cool_branch

git push -f origin my_cool_branch:my_cool_branch

Это переставит указатель ветки my_cool_branch в репозитории origin на её новое
положение. Тут используется -f, так как это по сути non-fast-forward операция.
В этом случае master в удалённом репозитории останется на месте и от него начнётся
новое "основание" ветки.

Если при этом с веткой my_cool_branch работали другие люди, то после этого у них
могут быть трудности. Так что если ожидается какая-либо новая работа в
my_cool_branch, то вместо rebase можно использовать merge.
(Проблемы можно устранить git reset --hard в локальном репозитории)

Тогда "ветвление и слияние" останется в истории ветки master, но зато
ветка my_cool_branch сможет продолжить своё развитие.

Когда в репозитории есть локальные ветки master и my_cool_branch, merge
делается так:

git checkout master
git merge my_cool_branch
git push

При merge возможны конфликты. Их придётся разрешить.

> 2. Сценарий формирования релиз-ветки. В том числе перетаскивания на неё
> отдельных коммитов (критических багфиксов итд) с транка.

Самое тупое - это просто создать релиз-ветку, и потом в рамках описанного выше
"ориентированного на задачи" workflow работать с этой веткой как с базой.

В частности, "перетаскивание коммита" - это выполнение операции
git cherry-pick в том месте workflow, где сказано "решать задачу".

> Также не лишне было бы понять, как _автоматически_ поддерживать в gitk
> отображение _текущего_ состояния репозитория. Делать каждый раз (а)
> обновление текущего клона репозитория и (б) нажатие каких-то кнопок в
> gitk -- очень напряжно.

gitk всегда показывает состояние локального репозитория.
Чтобы увидеть изменения в удалённом репозитории, сначала надо скопировать их в
локальный репозиторий командой git fetch.

Основной сценарий работы с gitk - запускать его, посмотреть что надо и
закрывать. У него довольно многое можно задать в командной строке (чтобы он
показал именно "что надо") - он принимает все параметры git rev-list.

"Долгоживущий" gitk - это нестандартный сценарий. Под это он не оптимизирован.
Хотя команда refresh в меню у него есть.

(cc) Nikita Youschenko, Mike Chistolinov

12 Replies to “Еще немножко про git”

  1. Хорошее описание workflow. Многое применимо и к другим DVCS. С небольшими поправками.

    А git, всё-таки, обоюдоострый меч. Какое руководство ни возьми, обязательно будет предложено сделать что-то, что будет опасно для целостности данных или репозитория, или что потом придётся «расхлёбывать». Так и ходишь, как по минному полю 🙂

    1. Ну почему же?

      Если понять git, то в дальнейшем он намного повышает эффективность работы. А понять в общем несложно – немного практики и всё.
      А ничего опасного я с ним давно не встречал.

      1. Понять его не сложно. Сложнее сделать так чтобы его поняли и все остальные 🙂

  2. Ну «хак» — это не совсем то слово. Эти вещи документированы и имеют объяснение, и понять их можно. Я бы назвал это «стиль git» 🙂 С переписыванием истории, с постоянной мыслью о том, какие коммиты опубликованы, а какие нет, и что можно делать, а что нельзя (и почему), с отрубленными головами (бесследно исчезающими при клонировании)… Я не прав или что-то упустил?

    Поэтому приходится решать такие задачи:

    Видимо, правильно будет запретить force на важных ветках на уровне pre-receive hook – подумаем над этим.

    … этот локальный коммит можно будет заменить
    или удалить.

    При этом я думаю, что самый большой недостаток git, что ему труднее научить новых пользователей. Особенно если они непрограммисты. Потому что в случае git их надо учить не только принципам работы распределённой системы управления версиям, не только командному интерфейсу системы, но и постоянно указывать на грабли, которые для удобства и гибкости повсюду в ней лежат. И что hg, что bzr в этом отношении имеют огромное преимущество.

    Впрочем, это исключительно личные преференции. Workflow очень хорошо и живо расписан.

    1. Ну «хак» — это не совсем то слово. Эти вещи документированы и имеют объяснение, и понять их можно. Я бы назвал это «стиль git» 🙂 С переписыванием истории, с постоянной мыслью о том, какие коммиты опубликованы, а какие нет, и что можно делать, а что нельзя (и почему), с отрубленными головами (бесследно исчезающими при клонировании)… Я не прав или что-то упустил?

      ИМХО, git-way это в некоторой степени UNIX-way к content-management’у. То есть ты можешь сделать именно так, как тебе кажется красивым и правильным. При этом что-то действительно красиво, а в чем-то ты просто криворукий извращенец. И нет никаких “внимание, этот каталог содержит системные файлы, вы уверены, что хотите его посмотреть?”.
      И точно также, как и в вопросах администрирования или программирования, да хоть и как в вопросах обращения с болгаркой, без понимания что ты делаешь, зачем и какие тут есть грабли, тут непросто и шишек набить вполне придется.
      Hg и bzr действительно более простые для новичков, но намного менее мощные инструменты. Вот только гит предлагает настолько много всяких мелких вкусностей, что разобравшись с ним, начинаешь его использовать очень много где.

    1. Ну там скорее не как пользоваться, а что у него внутри. Это не совсем для новичков.

      По поводу стандартной модели – конечно, хотя некоторые подходы более git-way чем другие.

  3. Как раз недавно прочитал цикл. Довольно хорошо дает понимание происходящего under the hood.

  4. Спасибо за статью! Чем git хорош, как верно замечено, уже, что он дает возможность самому выбирать стиль работы: с одним центральным репозиторием или у каждого разработчика свой, с множеством удаленных веток или без, с локальными ветками по именам задач, или вообще без них… Я вот тоже на своем блоге писал о собственном рабочем цикле, которым пользуюсь… правда забыл об удаленных ветках упомянуть (изредка пользуюсь). И получается так, что каждый, кто пишет статьи, что-нибудь новенькое привносит, чего раньше не знал и не пользовался. Вот и здесь я впервые узнал о rebase. Еще раз спасибо.

Leave a Reply

Your email address will not be published. Required fields are marked *