Pull to refresh

Нетривиальное слияние репозиториев с помощью git-filter-repo

Reading time 4 min
Views 4.9K

Это вторая часть истории про слияние репозиториев. Суть проблемы вкратце такова: надо слить репозиторий с подрепозиторием с сохранением истории. Решение на gitpython работало за 6 часов и выдавало удовлетворительный результат. Но переизбыток свободного времени и гвоздь в жопе врождённая любознательность привели меня к знакомству с волшебным миром git-filter-repo.

Что такое git-filter-repo?

Это замечательная программа, которая вызывает git fast-export с одной стороны, git fast-import с другой стороны, а между ними позволяет делать всякую фильтрацию. git fast-export, в свою очередь, просто берёт все объекты git-репозитория (а git-репозиторий представляет из себя не более чем объектную базу данных с объектами фиксированного вида) и печатает их текстом в стандартный вывод, так, чтобы для любого объекта Х, все объекты на который он ссылается выводились раньше самого объекта Х. git fast-import, как не трудно догадаться, делает обратную операцию: берёт эту простыню и раскидывает по файлам в папке .git.

Последствия написания статей

Написав прошлую статью, я понял, что всё это построение подграфа — лишняя операция. Ведь место, куда вставляются коммиты из secondary-репозитория очень легко найти: это те коммиты, где меняется файл secondary.version. Причём диапазон secondary коммитов считается как список (secondary.version до изменения)..(secondary.version после изменения). И, соответственно, можно просто бежать по графу коммитов и сразу делать необходимые операции. Переписав скрипт с учётом этого знания, удалось сократить время работы до примерно 3-х часов. Но, и это не предел. Ведь теперь коммиты оригинального репозитория обрабатываются все подряд линейно, то есть именно так, как их выводит git fast-export. А значит, можно использовать git-filter-repo.

Не только приложение

Очень быстро выяснилось, что использовать git filter-repo, как приложение не получится. Дело в том, что такой режим годится только для относительно простой фильтрации без состояния. Либо надо делать какие-то зубодробительные коллбеки с сохранением состояния бог знает где. Истинно, это не путь Дао. Однако, мудрый автор git filter-repo предусмотрел использование его как библиотеки. Библиотека состоит преимущественно из класса FastExportParser, который из вывода git fast-export создаёт объекты соответствующих классов (Blob, Commit и т.д.), каждый из которых имеет метод dump() для форматирования в вид приемлемый для git fast-import.

Итого

Берём класс RepoFilter как пример, создаём вход (git fast-export) и выход (git fast-import) для FastExportParser'а.

fep_cmd = ['git', '-C', args.source, 'fast-export', '--show-original-ids', '--progress=128'
           , '--signed-tags=strip', '--tag-of-filtered-object=rewrite', '--mark-tags'
           , '--fake-missing-tagger', '--reference-excluded-parents', '--all']
fep = subproc.Popen(fep_cmd, bufsize=-1, stdout=subproc.PIPE)
inpt = fep.stdout

fip_cmd = ['git', '-C', args.target, '-c', 'core.ignorecase=false'
           , 'fast-import', '--force']
fip = subproc.Popen(fip_cmd, bufsize=-1, stdin=subproc.PIPE, stdout=subproc.PIPE)
otpt = fip.stdin

processor = Processor(args.source, args.secondary, otpt, fip.stdout)
parser = gfr.FastExportParser(blob_callback = processor.blob_callback
                              , commit_callback = processor.commit_callback
                              , progress_callback = processor.progress_cb)


parser.run(inpt, otpt)
otpt.close()
inpt.close()

И пишем класс Processor c необходимыми коллбеками. Самый главный коллбек — коммитный. Там как раз и обнаруживается что конкретный коммит меняет secondary.version. Что, кстати, теперь намного проще сделать, так как git fast-export сразу выдаёт только те файлы которые поменялись в конкретном коммите. После того, как изменение обнаружено, запускается новый git fast-export с новым экземпляром FastExportParser'а, который забирает коммиты уже из secondary репозитория, проводит с ними минимальную работу и пишет в тот же самый поток git fast-import. Причём, многие блобы попадают в git fast-import по многу раз, но это не причиняет никаких неудобств, ведь все объекты git адресуются своими хэшами, и, соответсвенно, добавление объекта во второй раз не меняет репозиторий (такие операции называются идемпотентными). Класс Commit и коммитный коллбек выглядит вот так.

  class Commit(_GitElementWithId):
  """
  This class defines our representation of commit elements. Commit elements
  contain all the information associated with a commit.
  """

  def __init__(self, branch,
               author_name,    author_email,    author_date,
               committer_name, committer_email, committer_date,
               message,
               file_changes,
               parents,
               original_id = None,
               encoding = None, # encoding for message; None implies UTF-8
               **kwargs):
        pass
    
class Processor : 
    def process_commit(self, commit) :
        idx = len(commit.file_changes)
        commit.message = self.message_reformat(commit.message)
        while idx > 0 :
            idx -= 1
            fc = commit.file_changes[idx]
            if fc.type == b'M' :
                process_meth = self.MAP.get(fc.filename)
                if process_meth is not None:
                    commit.file_changes.pop(idx)
                    process_meth(commit, fc)

Как видите, process_commit вызывает методы, в зависимости от имени файла. Метод для secondary.version:

    def process_secondary_version(self, commit) :
        parent = commit.original_id.decode('ascii')
        res = subproc.run(['git', '-C', self.prepo, 'cat-file'
                           , 'blob', f'{parent}^:secondary.version']
                          , capture_output = True)
        hsh_from = res.stdout[:40].decode('ascii')
        if len(hsh_from) < 40 :
            sr_range = f'{hsh}^!'
        else :
            sr_range = f'{hsh_from}..{hsh}'
        self.sub_parsed = False
        self.super_commit = commit
        self.sub_top_hsh = hsh.encode('ascii')

        sub_fep_cmd = ['git', '-C', self.srepo, 'fast-export', '--show-original-ids'
                       , '--signed-tags=strip', '--tag-of-filtered-object=drop'
                       , '--import-marks=secondary.marks'
                       , sr_range]
        sub_fep = subproc.Popen(sub_fep_cmd, bufsize=-1, stdout=subproc.PIPE)
        self.sub_parser.run(sub_fep.stdout, self.output)
        sub_fep.stdout.close()

Эпилог

Новая реализация работает за 10 минут вместо 6-и часов. Мораль: правильные алгоритмы, это конечно хорошо, но лучшие инструменты дают лучшие результаты.

Tags:
Hubs:
+5
Comments 3
Comments Comments 3

Articles