Skip to content

Latest commit

 

History

24 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

git-xfer

Перенос коммитов между двумя несвязанными git-репозиториями — такими, у которых нет общего предка и никогда не было общего remote.

Типичный случай: один и тот же проект живёт в двух рабочих копиях (разные хостинги, разные машины, разные заказчики), истории независимые, но правки нужно гонять в обе стороны. Штатные средства тут не помогают: merge требует общей истории, зеркалирование переносит репозиторий целиком, а голый git cherry-pick не умеет ни показать, что уже перенесено, ни пережить конфликт в середине серии.

Самый короткий путь — через агента

Первый раз — дайте агенту ссылку и скажите словами:

Поставь инструмент https://github.com/StasPotapov/git-xfer, внутри есть скилл — с его помощью перенеси коммит a1b2c3d из ~/dev/repo-a в ~/dev/repo-b

Дальше он всё сделает сам: skills/git-xfer/SKILL.md — самодостаточная инструкция. По ней агент поставит инструмент, предложит подключить скилл, спросит пути и ветки, заведёт конфиг, проверит готовность, покажет, что уже переносили и где будут конфликты, и применит после вашего «да».

Дальше ссылка и установка не нужны — скилл вызывается напрямую. В Claude Code это /git-xfer, или просто словами: «перенеси вот этот коммит в личный репозиторий», и хватает хеша или заголовка.

SKILL.md — обычный текст, так что это не только про Claude Code: любому агенту, умеющему запускать команды в терминале, хватит ссылки на него.

Зачем инструмент, если агент и так умеет cherry-pick

Агенту с голым cherry-pick не на что опереться. Он не помнит, что переносил в прошлый раз, — и молча продублирует коммит или пропустит нужный. Не отличит конфликт от «коммит стал пустым, потому что это изменение уже там». Упрётся в конфликт на третьем коммите из десяти и бросит репозиторий в середине операции. Проверять придётся вам — то есть ровно то, от чего вы хотели избавиться.

git-xfer забирает у агента всё, что можно посчитать: что уже перенесено, где будут конфликты, как довести серию после паузы. Агент решает, что переносить, и разбирает конфликты.

Установка

Нужен Python ≥ 3.11 (ради tomllib) и git ≥ 2.45 (ради cherry-pick --empty). Сторонних зависимостей нет — только стандартная библиотека.

uv tool install git+https://github.com/StasPotapov/git-xfer
# или
pipx install git+https://github.com/StasPotapov/git-xfer

Зовите через дефис — git-xfer. Форма git xfer через пробел тоже работает, но зависит от того, как git разрешает подкоманды.

Без установки: клон — симлинк, обёртка под конкретный python или запуск модулем
git clone https://github.com/StasPotapov/git-xfer.git ~/tools/git-xfer
python3 --version          # должно быть 3.11 или новее

Дальше — любой из трёх способов. Обновление в любом случае одно: git pull в клоне, переустанавливать нечего.

1. Симлинк в PATH

mkdir -p ~/.local/bin
ln -s ~/tools/git-xfer/bin/git-xfer ~/.local/bin/git-xfer
# если ~/.local/bin ещё не в PATH:
echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.zshrc && exec zsh

Имя файла менять нельзя: git-xfer — это и имя команды, и то, по чему git находит подкоманду. Алиас шелла вместо симлинка не годится — git его не видит.

2. Обёртка вместо симлинка — когда нужен конкретный интерпретатор

cat > ~/.local/bin/git-xfer <<'EOF'
#!/bin/sh
exec python3.11 "$HOME/tools/git-xfer/bin/git-xfer" "$@"
EOF
chmod +x ~/.local/bin/git-xfer

Пригодится там, где первым в PATH лежит старый python3 — на macOS это обычно системный 3.9.

3. Совсем без PATH — модулем

PYTHONPATH=~/tools/git-xfer python3 -m gitxfer list -p myproj --to b

В этом варианте ни git-xfer, ни git xfer работать не будут — только python3 -m gitxfer.

Не работает — что проверить

git: 'xfer' is not a git command — утилиты нет в PATH. Из просто скачанного клона git xfer работать не может: git ищет в PATH исполняемый файл git-xfer. Поставьте или положите симлинк. До этого зовите из каталога клона: python3 -m gitxfer … или ./bin/git-xfer ….

command -v git-xfer ничего не печатает — симлинк создан, но ~/.local/bin не в PATH. Проверить: echo $PATH | tr : '\n' | grep local/bin.

attempted relative import with no known parent package — запустили файл пакета напрямую (python3 gitxfer/cli.py). Так нельзя. Свежие версии на это отвечают подсказкой вместо трейсбека.

нужен Python 3.11 или новее — python3 в PATH слишком старый (на macOS системный обычно 3.9). Ставьте через uv/pipx, они фиксируют интерпретатор, либо зовите python3.11 -m gitxfer ….

FileNotFoundError из os.getcwd() — каталог, в котором стоит терминал, переехал или удалён. Достаточно cd в существующий каталог. Свежие версии этим не падают.

Что-то упало непонятно — есть журнал: путь покажет git-xfer status, по умолчанию ~/.local/state/git-xfer/git-xfer.log. Там каждый вызов git с кодом возврата и полный трейсбек последней ошибки.

Конфиг

Штатное место — ~/.config/git-xfer/config.toml (учитывается XDG_CONFIG_HOME). Утилита его только читает, правится он руками; шаблон создаёт git-xfer init.

Профиль — это пара репозиториев, а не направление. Направление выбирается в момент вызова флагом --to, поэтому на одну пару нужен один профиль, а не два.

[defaults]
scan_limit = 30         # сколько коммитов источника показывать
dedup_window = 5000     # как глубоко помним, что уже переносили
patchid_window = 1000   # окно эвристики ≈
trailer = false         # не дописывать (cherry picked from commit …)
keep_author = false     # автор перенесённого коммита — тот, кто переносит
squash = false          # не схлопывать серию в один коммит
git_timeout = 600       # потолок на один вызов git, секунды (0 — без него)
log = true              # журнал прогонов

[profiles.myproj]
a = "/path/to/repo-a"
b = "/path/to/repo-b"
branch = "master"          # одна ветка с обеих сторон

[profiles.myproj-release]  # та же пара, другие ветки
a = "/path/to/repo-a"
b = "/path/to/repo-b"
a_branch = "release/1.x"   # если ветки называются по-разному
b_branch = "stable"

[profiles.myapp]           # проект лежит в подкаталоге монорепозитория
a = "/path/to/monorepo"
a_prefix = "apps/mobile"
b = "/path/to/personal"    # тут проект в корне — b_prefix не нужен
branch = "master"

--to b тащит из a в b, --to a — обратно. Без флага в терминале утилита спросит.

trailer, keep_author, squash и окна можно переопределить внутри профиля — профиль главнее [defaults], а флаг вызова главнее профиля.

Три ключа решают, как будет выглядеть перенесённый коммит. Все три по умолчанию false, и false везде значит «не делать ничего сверх переноса»:

ключ false — по умолчанию true
trailer сообщение переносится один в один в конец сообщения дописывается (cherry picked from commit <sha>)
keep_author автор — вы, author date — момент переноса автор и его дата остаются от исходного коммита
squash сколько коммитов выбрали, столько и приедет вся выбранная серия становится одним коммитом

Разовые флаги названы так же и работают в обе стороны: --trailer / --no-trailer, --keep-author / --reset-author, --squash / --no-squash. Что действует прямо сейчас — три строки в выводе git-xfer status.

Что приезжает в целевой репозиторий

По умолчанию коммит переносится так, будто вы написали его сами: сообщение один в один, без приписок, автор и коммиттер — вы, author date — момент переноса.

У коммита в git два поля, и путают их постоянно: author — кто написал изменение (и author date), committer — кто применил его в этой истории. Обычный cherry-pick оставляет author от оригинала, а committer'ом ставит вас — отсюда и «в коммите двое». Кто такой «вы» — это user.name и user.email того репозитория, куда переносим, на машине, где запущен перенос.

keep_author author committer
false — по умолчанию вы, дата — момент переноса вы
true автор оригинала и его дата вы

Committer отдельной настройки не имеет: в git это всегда тот, кто применил коммит, и подменять его значило бы приписывать работу другому человеку.

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

[defaults]
trailer = true       # + строка (cherry picked from commit <sha>)
keep_author = true   # автор и author date остаются от оригинала

Разово, без правки конфига: --trailer / --no-trailer, --keep-author / --reset-author. Что действует сейчас, показывает git-xfer status строками «Сообщение:» и «Авторство:» — а если серия уже начата, он показывает её собственные опции: доигрывается она теми, с которыми начиналась.

Несколько коммитов — одним

Сколько коммитов выбрали, столько в цели и появится. Если нужен один общий:

git-xfer apply -p myproj --to b --commits 1-6 --yes \
  --squash --message "feat: экран профиля"

Без --message сообщением станут сообщения серии подряд, от старого к новому. Постоянно — squash = true в [defaults] или в профиле, разовое исключение — --no-squash.

Две комбинации, о которых стоит знать заранее:

  • со включённым trailer в собранном сообщении будет по строке (cherry picked from commit …) на каждый вошедший коммит — они едут как есть и в одну не схлопываются. Задайте --message, если это лишнее;
  • со включённым keep_author автором схлопнутого коммита становится автор первого коммита серии (и его author date): у одного коммита автор может быть только один.

Схлопывание — не отдельный механизм: серия применяется как обычно, коммит за коммитом, и только в самом конце сворачивается (reset --soft к точке старта плюс один commit). Поэтому конфликты, пауза и continue работают без оговорок, а сворачивается уже разрешённый результат. Одно следствие: abort посреди squash-серии оставляет уже применённые коммиты по отдельности — сворачивать в тот момент ещё нечего.

Цена выключенного трейлера — дедупликация. Это самый надёжный канал: он живёт в самой целевой истории и потому работает с любой машины и в обе стороны. Без него «уже переносили» утилита помнит только по своему state (каталог ~/.local/state/git-xfer, привязан к целевому репозиторию на этой машине) и по patch-id — эвристике, которая метит коммит значком ≈, но не прячет его.

Из этого следует и то, чего от state ждать не стоит: он односторонний. Файл заведён на тот репозиторий, в который переносили, и при смене направления не читается — на обратном пути уже перенесённые коммиты будут помечены ≈, а не −. Переносите в обе стороны, с двух машин или чистите state — включайте trailer = true.

Проект в подкаталоге

Бывает, что один и тот же проект в одном репозитории лежит в корне, а во втором — в подкаталоге монорепозитория. Тогда пути в коммитах не сходятся: apps/mobile/icons/logo.svg против просто icons/logo.svg, и перенос «как есть» создал бы лишнюю вложенность. Подкаталог объявляется ключом <сторона>_prefix, как a_prefix в примере выше.

Путь стороны — всегда корень репозитория. Указать в a сразу подкаталог нельзя: doctor это заметит и покажет, как переписать профиль. Разово, без конфига, то же делают флаги --source-prefix и --target-prefix.

Дальше всё как обычно, но с тремя следствиями:

  • --to b срезает префикс, --to a его надевает.
  • Коммиты, не задевшие подкаталог, в список не попадают. Значит --limit считает коммиты проекта, а не коммиты монорепозитория — иначе окно из 30 коммитов могло бы не содержать ни одного нужного.
  • Коммит, задевший и подкаталог, и файлы вне него, переносится частично — только внутренняя часть. Такая строка помечена в списке звёздочкой (+ *), чтобы это не случилось молча.

Обратный перенос ничего не удаляет за пределами подкаталога: база трёхстороннего слияния содержит только поддерево под префиксом, так что остальные каталоги монорепозитория для merge выглядят как «добавлено нами» и остаются на месте.

Какие коммиты не переносить

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

[defaults.skip]
subject = ["(?i)служебн", "^wip\\b"]  # регэкспы по заголовку коммита
commits = ["a1b2c3d"]                  # конкретные хеши, можно сокращённые
paths = ["*.lock", ".idea/*"]          # коммит только из таких файлов

[profiles.myproj.skip]                 # у профиля — свои, в дополнение к общим
subject = ["^chore\\(ci\\)"]

Правило не прячет коммит, а ставит на него пометку × с причиной. Дальше так:

  • list --new и блок «нет в цели» у compare показывают его отдельно, среди кандидатов его нет;
  • --commits all его пропускает и пишет об этом одной строкой;
  • названный явно (номером, диапазоном, --sha, в интерактивном выборе) в терминале вызывает вопрос «точно переносить?», а без терминала — отказ с кодом 1, пока не добавлен --include-skipped.

paths считаются от корня проекта — подкаталог стороны уже срезан, так что одно правило работает в обе стороны пары. Коммит исключается, только если под маски подходят все его файлы: в масках * проходит и через /. Опечатка в ключе или битый регэксп — ошибка конфига, а не молча выключенное правило.

Конфиг в своём месте, ветки мимо профиля, работа вообще без конфига

Свой путь к конфигу, если не хочется трогать ~/.config:

git-xfer init --config ~/tools/git-xfer.local.toml
git-xfer list --config ~/tools/git-xfer.local.toml -p myproj --to b

--config работает и до подкоманды, и после. Чтобы не писать его каждый раз — alias gx='git-xfer --config ~/tools/git-xfer.local.toml'. Если держите конфиг внутри клона, называйте файл что-нибудь.local.toml — .gitignore такие исключает, и реальные пути не уедут в историю.

Заводить профиль ради одного прогона по другой ветке не нужно:

git-xfer list -p myproj --to b -b feature/login    # обе стороны
git-xfer list -p myproj --to b --source-branch feature/login --target-branch integration

Флаг про одну сторону вторую не трогает: с профилем -p myproj --to b --source-branch feature/login возьмёт feature/login из источника и положит в ветку цели из конфига. Имя «то же самое» подставляется только там, где о второй стороне не знает никто — то есть без профиля. У разового прогона свой служебный ref, так что обычные профили он не задевает.

Можно и вовсе без конфига:

git-xfer list --source ~/dev/repo-a --target ~/dev/repo-b -b master
git-xfer apply --ask          # спросит репозитории и ветки
Рабочее состояние и журнал — где лежат и как выключить

Кэш patch-id, маппинг перенесённых коммитов и незавершённую очередь утилита держит в ~/.local/state/git-xfer/ (учитывается XDG_STATE_HOME) — вне рабочих репозиториев, в git ничего не попадает. Там же журнал прогонов: какие команды git запускались, с каким кодом, сколько заняли, на каком коммите встала серия и с какой ошибкой.

git-xfer status -p myproj --to b     # покажет путь к журналу и к state
tail -50 ~/.local/state/git-xfer/git-xfer.log

Журнал ротируется (2 МБ, 5 файлов) и включён по умолчанию. Выключить — log = false в [defaults] или --no-log разово; увести в свой файл — log_file в конфиге или --log-file.

Как этим пользоваться

Конфиг заполняется один раз. Дальше:

git-xfer list   -p myproj --to b              # что можно взять
git-xfer plan   -p myproj --to b --commits 3,4   # где будут конфликты
git-xfer apply  -p myproj --to b --commits 3,4 --yes
# конфликт → правите руками, git add …
git-xfer continue -p myproj --to b

Объекты источника подтягиваются сами, так что начинать можно прямо с list.

Когда вопрос не «что взять», а «чем они вообще разошлись», — compare:

git-xfer compare -p myproj --to b          # чего нет в b и что есть только в b
git-xfer compare -p myproj --to b --files  # плюс список различающихся файлов
git-xfer compare -p myproj --to b --json   # то же для агента и скриптов

Он показывает, какие файлы проекта сейчас отличаются, каких коммитов нет в цели (номера те же, что у list) и какие коммиты есть только в цели. Под таблицей — подсказки: у коммита файлы уже совпадают по обе стороны (переносить, скорее всего, незачем), те же файлы правили в цели (жди конфликта), коммит старше последнего перенесённого. Считается всё в целевом репозитории — источник не трогается. Советовать по этим данным, что переносить, умеет скилл. Номера в --commits — из вывода list при тех же --limit и профиле; вместо номеров можно --sha <хеш>. Запущенный без флагов выбора в терминале, apply спросит всё сам — профиль, направление и коммиты.

Одно ограничение, о котором стоит знать заранее: коммиты ложатся в ту ветку, которая сейчас выгружена в целевом репозитории, и профиль должен называть именно её. Ветка в профиле — заготовка на частый случай, а не единственно возможная: любой вызов принимает --target-branch <ветка> (и --source-branch, и -b для обеих сразу), так что заводить профиль под каждую ветку не нужно.

git-xfer status -p myproj --to b          # в профиле master, выгружена feature/login
git-xfer apply -p myproj --to b --target-branch feature/login --commits 1 --yes

Строку Ветка цели: печатает status: в ней видно и то, что записано в профиле, и то, что выгружено на самом деле. Если они разошлись, doctor и apply остановятся с кодом 2 и ничего не тронут — переключитесь сами или назовите выгруженную ветку флагом. Ветки нет вовсе — doctor скажет и это, отдельной формулировкой: опечатка в имени и желание завести новую ветку выглядят одинаково, и различить их может только человек.

Флаги ветки — часть адреса переноса: объекты источника такой вызов держит под своей ссылкой refs/xfer/*. Поэтому cleanup зовите с теми же флагами, которыми переносили, либо git-xfer cleanup --all.

Пометки в списке

значение откуда известно
+ новый ни один канал не сработал
− уже перенесён локальный маппинг в state, а если включён trailer — то и он
≈ похоже, перенесён совпал patch-id

≈ — эвристика, и она никогда не прячет коммит молча: строка остаётся в списке, выбрать её можно, а при переносе такой коммит обычно становится пустым и отбрасывается сам.

Рядом со статусом бывают ещё пометки — не про дедупликацию, а про охват и правила; сочетаются с любым из трёх значков (+*×):

значение
* коммит задевает и файлы вне подкаталога; приедет только внутренняя часть
× коммит подпал под правило skip — по умолчанию не переносится

* появляется только у профилей с префиксом (проект в подкаталоге), × — только при правилах в конфиге (какие коммиты не переносить).

Как это выглядит целиком — интерактивный прогон в обе стороны

Запуск без аргументов спрашивает профиль и направление:

$ git-xfer apply
Профиль:
  1. myproj
     /Users/me/dev/repo-a ↔ /Users/me/dev/repo-b  (master)
  2. myproj-release
     /Users/me/dev/repo-a ↔ /Users/me/dev/repo-b  (release/1.x / stable)
  выбор [1]: 1
Куда переносим:
  1. /Users/me/dev/repo-a (master)  →  /Users/me/dev/repo-b (master)
  2. /Users/me/dev/repo-b (master)  →  /Users/me/dev/repo-a (master)
  выбор [1]: 1

Дальше показывается, что можно взять:

 1 + d772cb8 2024-02-08 Ann Source chore: хвостовой коммит
 2 + 0f6ff9a 2024-02-06 Ann Source side: ветка
 3 + a2c0b9a 2024-02-05 Ann Source docs: переименование и правка
 4 + 47c2151 2024-02-04 Ann Source chore: бинарный ассет
 5 ≈ bacc39d 2024-02-03 Ann Source общая правка: shared.txt
 6 + f62bac9 2024-02-02 Ann Source fix: правка app (конфликтует)

  + новый: 5   ≈ совпал patch-id: 1   − уже перенесён: 0   (страница 1/1, коммитов 6)
Выбор [1,3,5-7 | all | !N | /текст — фильтр | n/p — страница | q — отмена]: 3,4

Пустой ввод — отмена, не «выбрать всё». Весь ввод проверяется целиком до того, как что-нибудь произойдёт.

Перед тем как что-то делать, печатается фактический порядок применения — список идёт newest-first, как git log, а применяется наоборот:

Порядок применения (old → new), коммитов: 2
    1. 47c2151 chore: бинарный ассет
    2. a2c0b9a docs: переименование и правка
Перенести 2 коммит(ов)? [y/N]: y

[1/2] 47c2151da5f9 chore: бинарный ассет
[2/2] a2c0b9af6e39 docs: переименование и правка

Перенесено: 2; пусто: 0; пропущено: 0

Обратно — то же самое, на шаге «Куда переносим» выбираете второй пункт. Перенесённое утилита узнаёт с обеих сторон и по кругу не гоняет:

$ git-xfer list -p myproj --to a
 1 − d3405fd 2024-02-05 Ann Source docs: переименование и правка
 2 − 7c72adc 2024-02-04 Ann Source chore: бинарный ассет
 3 + 758dd2e 2024-02-10 Bob Target target: правка app

  + новый: 1   ≈ совпал patch-id: 0   − уже перенесён: 2

Полный цикл одной стороны, если нужны все шаги явно:

git-xfer doctor -p myproj --to b     # предполётные проверки
git-xfer sync   -p myproj --to b     # подтянуть объекты source → target
git-xfer list   -p myproj --to b
git-xfer plan   -p myproj --to b -i
git-xfer apply  -p myproj --to b -i
git-xfer continue -p myproj --to b   # после разрешения конфликта
git-xfer cleanup  -p myproj --to b   # убрать служебные ссылки
Конфликт — что делать и чем это отличается от штатного cherry-pick

Серия встаёт на конфликтном коммите и возвращает код 3. Рабочее дерево в этот момент — обычное состояние cherry-pick: маркеры в файлах, незавершённая операция.

# правите файлы, потом
git add <файлы>
git-xfer continue -p myproj --to b   # доведёт коммит и докрутит очередь
git-xfer skip     -p myproj --to b   # бросить этот коммит, идти дальше
git-xfer abort    -p myproj --to b   # отменить текущий коммит

abort здесь честнее штатного: он откатывает только коммит, на котором встали, а всё уже перенесённое остаётся на месте. Штатный cherry-pick --abort на серии снёс бы и все ранее разрешённые конфликты.

Если вы закоммитили разрешение сами (git cherry-pick --continue руками), git-xfer continue это распознает и примет как есть.

Подкоманды

команда что делает
init создать шаблон конфига
doctor предполётные проверки
sync перенести объекты source → target
list таблица коммитов источника (--new — только непереносившиеся)
compare чего нет в цели и что есть только в ней (--files, --json)
plan сухой прогон: где будут конфликты, без касания рабочего дерева
apply перенести выбранные коммиты
continue / skip / abort управление паузой на конфликте
status состояние незавершённого переноса
cleanup убрать refs/xfer/* (--all, --state)

Общие флаги: -p/--profile, --config, -v (печатать вызовы git), -n/--dry-run, --no-log и --log-file. Направление и разовые переопределения — у всех подкоманд, кроме init: --to a|b, -b/--branch, --source-branch / --target-branch, --source / --target, --source-prefix / --target-prefix, --ask.

Флаги apply и коды выхода
флаг по умолчанию
--trailer / --no-trailer без трейлера: сообщение переносится один в один (ключ trailer)
--keep-author / --reset-author автор сбрасывается на того, кто переносит (ключ keep_author)
--squash / --no-squash без схлопывания: коммит в коммит (ключ squash)
--message TEXT сообщение схлопнутого коммита; без него — сообщения серии подряд
--empty=drop|keep|stop drop — ставший пустым коммит уже перенесён
--hooks хуки отключены (core.hooksPath=/dev/null)
--gpg-sign --no-gpg-sign
--keep-committer-date committer date = сейчас
--allow-merges merge-коммиты не попадают даже в таблицу
--include-skipped коммиты под правилами skip не берутся (есть и у plan)

Пара с ключом конфига работает как обычно: значение из [defaults], поверх — из профиля, поверх — флаг этого вызова.

код значение
0 успех
1 ошибка вызова, разбора аргументов или конфига
2 предполётная проверка не прошла
3 конфликт, перенос на паузе — ждём continue
4 ошибка git
130 Ctrl-C

Скилл для Claude Code

Про установку и обычный сценарий сказано в начале; здесь — что скилл делает внутри и как настроить его поведение.

Инструкция в skills/git-xfer/SKILL.md самодостаточная: агенту хватит ссылки на этот репозиторий. Порядок у него жёсткий — doctor, sync, list (или compare, когда просят сравнить и посоветовать), разбор пометок и диффов, предложение набора коммитов с обоснованием, сухой прогон и только потом apply, после вашего «да». Интерактивные флаги (-i, --ask) ему запрещены: у него нет терминала, и зависнуть на вопросе он не должен. Частые поломки опознаёт по таблице симптомов, а не гадает.

Единственное, что стоит настроить, — насколько самостоятельно он разбирает конфликты:

[agent]
resolve_conflicts = "mechanical"
значение что делает агент при конфликте
ask ничего не правит: показывает суть и предлагает решение
mechanical механические чинит сам (импорты, соседние строки, форматирование, сгенерённое), смысловые выносит вам
auto разбирает всё сам и отчитывается постфактум

Настройку читает скилл; сама утилита по ней ничего не делает — но проверяет значение (опечатка не окажется молчаливой) и показывает его в git-xfer status. Разовое указание словами главнее конфига.

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

Под капотом

Как это устроено — fetch по пути, очередь по одному коммиту, проекция префикса, четыре канала дедупликации

Объекты переносятся fetch'ем по локальному пути, без git remote add:

git -C <TARGET> -c protocol.file.allow=always \
  fetch --no-tags --no-write-fetch-head -- <SOURCE> \
  '+refs/heads/<branch>:refs/xfer/<профиль>/head'

--no-tags обязателен — иначе чужие теги осядут в refs/tags/* рабочего репозитория. Созданная ссылка в refs/xfer/* держит объекты от gc и убирается подкомандой cleanup. Ни remote'ов, ни FETCH_HEAD, ни тегов в целевом репозитории не прибавляется.

Коммиты применяются по одному, а не одной командой cherry-pick A B C. При одном rev git идёт в single_pick() и каталог .git/sequencer не создаёт вовсе — поэтому своя очередь в state даёт точный прогресс, опции на каждый коммит и, главное, честный abort: он откатывает только текущий коммит, тогда как штатный cherry-pick --abort на серии сносит все уже разрешённые конфликты.

Cherry-pick через несвязанные истории работает без оговорок — это трёхсторонний merge с базой C^, merge-base не вычисляется. Для корневого коммита база — пустое дерево.

Смена префикса — это проекция коммита, а не второй бэкенд. cherry-pick пути не переписывает, поэтому перед ним синтезируется временный коммит: берётся поддерево <sha>:<префикс>, такое же поддерево у первого родителя, при необходимости оба вкладываются под префикс другой стороны, и пара деревьев склеивается в коммит с одним родителем. Диффом получается ровно нужный набор путей, а весь остальной механизм — очередь, конфликт, continue/abort, сухой прогон — работает без единой оговорки. Трейлер, если он включён, вписывается не флагом -x (тот указал бы на синтетический коммит), а прямо в сообщение проекции, и ведёт на оригинал.

Пока перенос стоит на паузе, CHERRY_PICK_HEAD указывает на этот синтетический коммит: git show CHERRY_PICK_HEAD покажет правильное сообщение и автора, но родителя из реальной истории у него нет. От gc его держит ссылка refs/xfer/<профиль>/pick, которая снимается по окончании серии, при abort и подкомандой cleanup.

Дедупликация — четыре канала, по убыванию надёжности: трейлер -x в целевой истории; трейлер самого коммита-источника, указывающий на коммит, который уже лежит в цели (так выглядит обратное направление); локальный маппинг src→dst в state; patch-id. Два трейлерных канала работают, только если трейлер включён (trailer = true) — по умолчанию его нет, и остаются маппинг и patch-id. Маппинг при этом работает лишь в ту сторону, в которую переносили: state заведён на целевой репозиторий (sha1(realpath(target))), и в обратном направлении это уже другой файл.

Схлопывание — это reset --soft в конце, а не отдельный бэкенд. Серия проигрывается обычным путём, со своей очередью, конфликтами и continue; когда очередь пуста, HEAD откатывается на точку старта (progress.head_before) с сохранением дерева и индекса, и делается один коммит. Маппинг src→dst переписывается на него для всех вошедших коммитов — иначе дедупликация сослалась бы на коммиты, которых в истории больше нет.

Авторство переписывается после коммита, а не во время. У cherry-pick своего --reset-author нет: автора он всегда берёт из исходного коммита. Поэтому сразу после каждого успешного шага — и после continue тоже — идёт git commit --amend --reset-author --no-edit, пока коммит ещё вершина ветки. С keep_author = true этого шага нет вовсе. Служебные коммиты git-xfer (этот amend и схлопывание) идут мимо хуков всегда, даже при --hooks: --hooks значит «прогнать хуки на переносимом коммите», и это делает cherry-pick, а pre-commit, которого у cherry-pick нет вовсе, порвал бы серию на доводке.

patch-id считается сам, git cherry не используется. В cmd_cherry() аргумент <limit> применяется уже после get_patch_ids(), поэтому при несвязанных историях patch-id считаются по всей целевой истории — на большом репозитории это минуты. Здесь вместо этого окно patchid_window и кэш в state.

Сухой прогон не трогает рабочее дерево: git merge-tree --write-tree цепочкой по всей очереди, выходное дерево становится базой для следующего коммита. Одна оговорка: у профиля с префиксом plan (и любая команда под -n) всё же пишет в целевой репозиторий loose-объекты — деревья и коммиты проекции. Ни одна ссылка на них не заводится, в историю они не попадают и подбираются git gc, как и объекты источника после cleanup.

Ограничения и побочные эффекты
  • Первый sync притащит полный pack от кончика ветки источника. Общего предка нет, negotiation работать не на чем. Блобы дедуплицируются по SHA, так что на практике это commit- и tree-объекты — десятки процентов размера репозитория, но не второй репозиторий целиком.
  • ≈ — эвристика. patch-id --stable игнорирует пробельные различия, а одинаковые диффы в разных местах истории дают одинаковый id. Поэтому такой коммит помечается, но не скрывается.
  • Merge-коммиты по умолчанию не переносятся. -m 1 схлопывает влитую ветку в один коммит — почти никогда не то, что нужно. Флаг --allow-merges есть, но применяйте осознанно.
  • --keep-committer-date ломает предположение git о неубывающих таймстампах коммитера; git log --since и подобное начнут врать.
  • Хуки отключены. Переносится уже проверенный коммит, и падающий линтер не должен рвать серию посреди очереди. Вернуть — --hooks.
  • Сабмодули переносятся как гитлинк: указатель обновится, содержимое — нет. doctor предупредит.
  • rerere может молча подставить прошлое разрешение конфликта. doctor предупредит.
  • Различия core.autocrlf / .gitattributes между репозиториями ломают и patch-id, и сам merge. doctor предупредит.
  • Коммит на границе подкаталога приезжает наполовину — часть вне префикса остаётся в источнике. Строка помечена *, но решение за вами.
  • Переименование через границу подкаталога превращается в удаление (файл уехал наружу) или в добавление целиком (приехал внутрь): за пределами префикса второй половины пары просто нет.
  • Rename-детекция при обратном переносе видит весь репозиторий вне префикса как «добавленные файлы» и в редком случае может спарить удалённый внутри префикса файл с похожим снаружи. Разрешается вручную, как обычный конфликт.
  • После cleanup объекты источника становятся недостижимыми, и git fsck покажет их как dangling. Это ожидаемо; освободит их git gc.
  • Конфиг только читается. Профиль с source == target блокируется.
Проверка — сквозной прогон на одноразовых репозиториях

Рабочие репозитории не трогает, всё живёт во временном каталоге и удаляется в конце:

sh tests/smoke.sh          # 346 проверок
KEEP=1 sh tests/smoke.sh   # оставить стенд для разбора

Стенд (tests/fixture.sh) собирает две пары несвязанных репозиториев. Первая — с одинаковым стартовым деревом и разошедшимися историями: конфликтующая правка, дубль уже имеющегося изменения, корневой коммит, бинарный файл, переименование и merge-коммит. Вторая — монорепозиторий с проектом в подкаталоге и тот же проект в корне отдельного репозитория: коммиты внутри подкаталога, снаружи и на границе, переименование через границу и влитая merge-ом ветка.

Что не входит в эту версию
  • бэкенд format-patch + am -3 для случая «в целевой репозиторий нельзя добавить вообще ничего» (точка расширения заложена в transfer.py);
  • первичный переезд всей истории подкаталога разом — этим занимается git subtree split, здесь переносятся выбранные коммиты.

Лицензия

MIT.

About

CLI для переноса коммитов между несвязанными git-репозиториями: дедупликация, сухой прогон конфликтов, честный abort

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages