Git: творческое переписывание истории

Пришлось на днях вытащить из одного большого git репозитория (~22k коммитов) несколько файлов и прилепить их к другому репозиторию с сохранением истории. Использовал несколько типов фильтров (git filter-branch), ниже — небольшая инструкция, как это делалось.

Допустим, у нас есть проект proj, из которого необходимо добыть libraries/src/main/java/vu/ya/Class1.java , libraries/src/main/java/vu/ya/Class2.java и libraries/test/main/java/vu/ya/Class1Test.java и переместить эти файлы в новый репозиторий с сохранением всех коммитов, который к ним относятся. В директории libraries/src/main/java/vu/ya есть множество других файлов и директорий которые, соответственно, нам ненужны.
Итак…
1. Сперва скопируем проект на ramdrive, поскольку любое переписываение истории весьма нагружает IO. Не забываем удалить origin, чтобы случайно чего не отправить.

rsync -a workspace/proj /tmp/ ; cd /tmp/proj ; git remote remove origin

2. Исходя из того, что все необходимые файлы находятся в одной директории libraries, сначала фильтруем дерево изменений по этой директории. Можно, конечно, этого не делать и сразу отфильтровать историю только для необходимых файлов, но это будет ооочень медленно (ведь у нас 22к коммитов). Коммитов, связанных с libraries будет наверняка меньше на порядок, а subdirectory-filter в гите работает за считанные секунды.

git filter-branch --prune-empty --subdirectory-filter libraries

В итоге всё, что было раньше в libraries, у нас переместилось в корень /tmp/proj. Там же будет куча всякого мусора в статусе untracked (git status) — директории и файлы из предыдущего состояния дерева, их нужно удалить вручную (rm …).
3. Удаляем все теги, т.к. они больше не актуальны.

git tag | xargs git tag -d

Также удаляем все ветки, кроме текущей — master (git branch -a для вывода веток).
4. Любое переписывание истории в git создаёт новые коммиты, оставляя старые нетронутыми. И перед фильтрацией истории изменений по имени файлов необходимо очистить репозиторий от “мусора” — гит “забудет” обо всех коммитах, на которые нет ссылок. То есть, у нас останется дерево только тех коммитов, на которые ссылается ветка master.

git reflog expire --expire-unreachable=now --all
git gc --prune=now

Собсно, посмотреть количество коммитов можно с помощью команды:

git rev-list HEAD --count

5. И, собственно, отфильтруем оставшуюся историю, оставив лишь коммиты для необходимых нам файлов

git filter-branch -f --prune-empty --index-filter '
    git ls-tree -r --name-only --full-tree  $GIT_COMMIT | \
    grep -v "\(Class1\|Class2\|Class1Test\).java" | \
    grep -v "^git-changelog" | \
    xargs git rm -q --ignore-unmatch --cached -r || true' -- --all

6. Посмотрим на наше новое дерево истории:

git log --oneline --graph

Как видим, коммиты отфильтровались, но может остаться много пустых merge points (если в проекте повсеместно использовался rebase и не было pull-реквестов, то их может и не быть). Merge point соединяет 2 коммита в один, соответственно, имеет 2 parent’a. Параметр –prune-empty в git filter-branch, отвечающий за удаление пустых коммитов при фильтрации, удаляет только коммиты с 1 родителем. Беда…
Всопользуемся таким трюком: перепишем историю, перенеся во всех коммитах наши файлы в какую-то директорию, а после снова сделаем subdirectory-filter.

git filter-branch -f --prune-empty --tree-filter '
    if [ ! -e temp-dir ]; then
        mkdir temp-dir
        git ls-tree --name-only $GIT_COMMIT | xargs -I files mv files temp-dir
    fi' HEAD
git filter-branch -f --prune-empty --subdirectory-filter temp-dir

7. Осталось только склонировать этот репозиторий

cd ~/workspace/; git init new-repo
cd new-repo ; git remote add -f filtered /tmp/proj
git pull filtered master

Ну вот и всё.