Перенос коммитов между двумя несвязанными 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 не на что опереться. Он не помнит, что
переносил в прошлый раз, — и молча продублирует коммит или пропустит
нужный. Не отличит конфликт от «коммит стал пустым, потому что это
изменение уже там». Упрётся в конфликт на третьем коммите из десяти
и бросит репозиторий в середине операции. Проверять придётся вам — то
есть ровно то, от чего вы хотели избавиться.
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 |
Про установку и обычный сценарий сказано в начале; здесь — что скилл делает внутри и как настроить его поведение.
Инструкция в 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.