Ни один ответ не будет полностью удовлетворительным для всех в каждом случае.Это потому, что вы буквально не можете скопировать файл историю из одного репозитория Git в другой по той простой причине, что Git не имеет историю файлов.Вы не можете удалить файл из (существующей) истории по другой, но связанной причине.Но то, что вы можете получить , может быть достаточно хорошим.
История Git - это коммитов, а коммиты неизменны
КакЯ уже много раз говорил, Git's raison d'être - это коммит.Что делает Git - это сохраняет коммиты, плюс немного больше, чтобы сделать их более полезными. extra означает, что иногда , вы можете сделать то, что достаточно для того, что вы хотите - хотя это, конечно, зависит именно от того, что вы хотите - или, возможно,что вы согласитесь.Давайте внимательно посмотрим на коммиты и посмотрим, как они являются историей.
Каждый коммит является в основном автономной сущностью.При фиксации сохраняется полный снимок всех файлов - то есть всех файлов, относящихся к этой фиксации, то есть - вместе с некоторыми метаданными .Каждый уникальный коммит уникально идентифицируется своим хеш-идентификатором.Вот фактический коммит из репозитория Git для самого Git (с @
изменено на пробел, чтобы, возможно, немного сократить спам):
$ git cat-file -p b5101f929789889c2e536d915698f58d5c5c6b7a | sed 's/@/ /'
tree 3f109f9d1abd310a06dc7409176a4380f16aa5f2
parent a562a119833b7202d5c9b9069d1abb40c1f9b59a
author Junio C Hamano <gitster pobox.com> 1548795295 -0800
committer Junio C Hamano <gitster pobox.com> 1548795295 -0800
Fourth batch after 2.20
Signed-off-by: Junio C Hamano <gitster pobox.com>
Это не такКонечно, GitHub отображает его, но это внутренний объект Git, в котором полностью сохраняется коммит.Сохраненный снимок получается через строку tree
.Строка parent
перечисляет коммит, который приходит до этого коммита , который сам по себе является коммитом слияния, так что он имеет две parent
строки.
здесь важны следующие вещи:
Коммит идентифицируется по его хэш-идентификатору, например, b5101f929789889c2e536d915698f58d5c5c6b7a
.Вот как любой Git во вселенной знает, имеет ли он этот коммит: либо у вас есть этот хэш-идентификатор, так что у вас есть этот коммит, либо нет, так что выне.
В коммите содержится список tree
, который является сохраненным моментальным снимком.
В коммите перечислены хэш-идентификаторы) его родителей или родителей.
Что означает этот , так это то, что Git нужен только хэш-идентификатор последнего коммита.Предположим, мы представляем этот большой некрасивый хэш-идентификатор одной буквой, например H
(для hash
).Мы говорим, что commit H
хранит хеш-идентификатор своего родителя, который мы представим как G
вместо другой большой уродливой строки.Затем совершите H
очков до commit G
:
G <-H
Но G
- это коммит.Это означает, что он хранит хеш-идентификатор его родителя, который мы можем назвать F
:
... <-F <-G <-H
и, конечно, F
хранит хеш-идентификатор E
, ии так далее, в цепочке задом наперед.Цепочка может разветвляться и повторно объединяться, и если бы мы шли вперед, а не назад, разветвление происходило бы, когда мы делали ветви, и повторное объединение происходило бы, когда мы объединяли ветви.Но поскольку Git фактически работает задом наперед, разветвление происходит при слиянии;повторное объединение происходит, когда у нас заканчивается объединенное содержимое:
I--J
/ \
...--F--G--H M--N--...--T <-- master
\ /
K--L
В любом случае эта цепочка является историей Git.Элемент, который предоставляет хэш-идентификатор последнего коммита в цепочке, как показано на рисунке выше, имя ветви , например master
.
Это все, что есть в Git. Нет истории файлов, есть только коммиты.Мы находим коммиты, начиная с tip commit, например, T
, чей ID хеша мы находим по имени, например master
.Мы добавляем новую историю - новые коммиты - в репозиторий, делая новый коммит U
, чей parent равен T
, а затем изменяя имя master
, чтобы указать нановый коммит U
.
фиксируетнеизменны , потому что их настоящие имена - их хэш-идентификаторы - вычисляются путем запуска криптографической контрольной суммы над всего содержимого фиксации. Если бы мы взяли вышеупомянутый коммит и изменили что-нибудь о нем - например, сохраненные отметки даты в строке author
или committer
, или сообщение журнала, или снимок tree
- мы должны были бы вычислить новую контрольную сумму по новым данным. Эта контрольная сумма будет другой, и вместо изменения существующего коммита H
мы просто получим новый коммит H'
:
...--F--G--H--I--J <-- master
\
H' <-- need-a-name-here
Этот новый коммит H'
имеет G
в качестве родителя, поэтому H'
это просто ветвь. Теперь мы должны изобрести имя ветки, чтобы хранить хэш-идентификатор нового коммита H'
, который является копией H
, но что-то изменилось. Мы не изменили ни одного коммита, мы просто добавили новый коммит.
Но я могу запустить git log --follow somefile.ext
, разве это не история файлов?
Может быть, это так! Но это не хранится в Git . То, что хранится в Git - это коммиты. git log
сделал, чтобы начать с какого-то имени ветви, например master
, и найти там коммит - коммит tip ветви. Этот коммит имеет хэш-идентификатор, сообщение журнала и снимок. Конечно, Git смог найти коммит parent коммита, как это было сохранено в коммите tip.
Теперь самое сложное. Все это происходит в большом цикле, работая над каждым коммитом, по одному коммиту за раз. Git выбирает показывать или нет коммит, на котором он работает , и для git log somefile.ext
:
Git извлекает снимок родительского коммита во временную область.
Git извлекает снимок фиксации во временную область.
(Это на самом деле не извлекает коммитов, но если вы подумаете об этом таким образом, это может иметь больше смысла. На самом деле он просто сравнивает хэш-идентификаторы внутри дерева, что достаточно. Позже, если вы попросили git log
показать различия, это действительно делает частичное извлечение. Но на самом деле это всего лишь оптимизация.)
Теперь git log
сравнивает два снимка. somefile.ext
изменился? Если это так, показать этот коммит.
Показав или не показав этот коммит, перейдите к его родителю.
Без --follow
, это все , что git log somefile.ext
делает. Вы видите синтетическую «историю файлов», состоящую из подмножества истории фиксации, в которой файл изменился с родительского на дочерний. Вот и все! То, что вы видели, было выбранная история коммитов . Вы можете вызвать эту "историю файлов", если хотите, но она вычисляется динамически из истории фиксации, которую Git фактически хранит.
Добавление --follow
говорит git log
сделать еще одну вещь: сравнивая два коммита, проверьте, не предполагает ли сравнение, что в родительском коммите somefile.ext
имел другое имя пути, Если родительский коммит вызвал файл oldname.dat
, например, git log --follow
переключает имена , когда он возвращается на один шаг назад в истории коммитов.
Здесь есть некоторые проблемы, особенно в отношении коммитов слияния. Коммит слияния - это коммит с двумя родителями вместо одного. Git буквально не может показать оба пути одновременно - он движется назад по истории коммитов, по одному коммиту за раз. Таким образом, когда он сталкивается с этими слияниями - вот где история расходится, потому что Git работает задом наперед - он обычно выбирает только одну ветвь истории , чтобы следовать.
(ДетальЗдесь довольно сложно. См. Раздел «Упрощение истории» документации git log
, но это тяжело. При запуске без определенных имен файлов, чтобы показать все коммиты, git log
по умолчанию опускается на обе ветви слияния, что немного сложно описать правильно : здесь мы должны ввести понятие очереди приоритетов . Линейная история, без слияний, позволяет избежать всей этой путаницы, и о ней легче думать.)
Теперь вернемся к проблеме
Давайте вернемся к исходному, краткому изложению желаемого результата:
Мне нужно переместить несколько файлов из одного хранилища в другое, сохраняя при этом их историю изменений.
То есть мы хотим, чтобы файлы, взятые из коммитов из RepoA, каким-то образом появлялись в коммитах, находящихся в RepoB.
Мы можем сразу увидеть проблему: история этих файлов действительно всех коммитов в RepoA или, в лучшем случае, некоторое подмножество коммитов из RepoA . Каждый из этих коммитов представляет собой полный снимок из всех своих файлов.
Более того, если мы возьмем эти снимки - либо в целом, либо в некоторой уменьшенной форме - и поместим их в RepoB, , эти снимки не будут такими же, как любой существующие снимки в RepoB. Давайте рассмотрим простой конкретный пример, в котором RepoA имеет четыре снимка A-B-C-D
в виде красивой линейной цепочки, а RepoB - еще четыре E-F-G-H
, аналогично:
RepoA:
A--B--C--D <-- master
RepoB:
E--F--G--H <-- master
Если мы просто скопируем все коммиты из RepoA в RepoB без изменений, мы получим это в RepoB:
E--F--G--H <-- master
A--B--C--D <-- invent-a-name-here
Это явно не то, что мы хотим. Мы можем сделать что-то, и это то, о чем все ответы, на которые вы смотрели,
Что мы можем сделать здесь
Если мы хотим somefile.ext
из RepoA, и он сначала создается в коммите B
, а затем изменяется в коммите D
, то, что мы можем сделать, это сделать два новых коммита I
и J
с только одним файлом . Мы можем сделать их где угодно - все Gits равны - поэтому давайте сделаем RepoC, клонируя RepoA, а затем сделаем их в RepoC, в основном только для иллюстрации:
$ git clone <url-of-RepoA> repo-c
$ cd repo-c
$ git checkout --orphan for-transplanting
$ git rm -rf . # empty the index and work-tree
$ git checkout <hash-of-B> -- somefile.ext # get the first copy of the file
$ git commit -m 'initial commit of somefile.ext' # and commit it
$ git checkout master -- somefile.ext # get the 2nd and last copy
$ git commit -m 'update somefile.ext' # and commit that one
Теперь RepoC содержит:
A--B--C--D <-- master, origin/master
I--J <-- for-transplanting
Теперь мы можем копировать коммиты I
и J
в RepoB:
$ cd <path-to-repo-B>
$ git fetch <path-to-repo-C> for-transplanting:for-transplanting
, что дает нам это в RepoB:
E--F--G--H <-- master
I--J <-- for-transplanting
где коммиты I
и J
имеют нужный файл.
Этот файл находится в истории J
-then- I
-then-stop , которая состоит из этих двух коммитов. (Трюк git checkout --orphan
убедился, что когда мы сделали коммит I
, у него не было родителя - это был корневой коммит, как и самый первый коммит, который мы сделали бы в новом, пустом репозитории. Помните, что все коммиты, с их уникальные хеш-идентификаторы универсальны для каждого Git-репозитория: у вас либо этот коммит с его хеш-идентификатором, либо у вас его нет. У RepoB их не было, и теперь, после git fetch
, у RepoB имеет их.)
Эти истории, очевидно, не связаны: невозможно прыгнуть с J
на H
-обратную цепь и обратно, и наоборот. Но теперь мы можем сказать Git «жениться» на коммитах H
и J
, создав новый коммит K
:
$ git checkout master
$ git merge --allow-unrelated-histories for-transplant
При этом используется (несуществующий, подделанный через пустое дерево ) действительно пустой коммит в качестве базы слияния, так что все файлы в H
создаются заново и все файлы в J
(только один файл) являются (являются) вновь созданными. Он объединяет эти изменения - добавляет все файлы в ничто и добавляет somefile.ext
в ничто - что легко сделать, применяет эти изменения к пустому дереву, в котором нет файлов, и фиксирует результат как новый коммит K
:
E--F--G--H--K <-- master
/
I---------J <-- for-transplanting
СинтИзящная «история файлов» вашего нового файла somefile.ext
теперь можно найти, посмотрев на K
, увидев, что файл существует в J
, но не в H
, и следуя этой ветви в обратном направлении.Файл существует в I
и J
и отличается, поэтому будет показан коммит J
.Затем Git переходит к I
.Файл не существует в несуществующем коммите до I
, поэтому он явно отличается в I
и показывается коммит I
.Тогда нет больше коммита, к которому можно вернуться, поэтому git log
останавливается.
Обратите внимание, что мы можем сделать I
и J
в RepoA
напрямую.Или мы можем скопировать все коммиты RepoA
(A-B-C-D
) в RepoB
, затем сделать I
и J
в RepoB, затем удалить все следы имен, которые привели к коммитам A-B-C-D
.Неиспользуемые / не имеющие ссылок коммиты в конечном итоге уйдут по-настоящему (обычно через 30 дней), и тем временем вы их не увидите, и они не будут вас беспокоить;они просто займут немного места на диске.Реальное преимущество использования RepoC
состоит в том, что мы можем экспериментировать там, и если что-то пошло не так, просто взорвать все это и начать все сначала.
Теперь у вас есть более сложная проблема
Наконец, если все скопировано правильно в целевой репозиторий, мне нужен чистый способ удалить (скрыть?) Исходные файлы из исходных репозиториев.
Тамне одинЕсть только грязные пути. Насколько грязный, или насколько грязный, зависит от ваших потребностей.
Опять же, исходный репозиторий имеет все свои коммиты. Все из них имеют все файлы.В нашем примере мы сделали упрощенное предположение, что было четыре коммита:
A--B--C--D <-- master
с somefile.ext
, впервые появившимся в B
, оставшимся неизменным в D
, а затем сохраненным с другим содержимым вD
.
Поскольку файл не находится в A
, вы можете сохранить коммит A
.Но вы должны создать замену B'
, которая похожа на B
- имеет те же метаданные, включая родительский A
, что и раньше - но имеет сохраненный снимок, который пропускает файл:
A--B--C--D <-- master
\
B' <-- ??? (we'll get to this)
СделавB'
из B
, теперь вам нужно сделать новый коммит C'
, который похож на C
за исключением двух вещей:
- его родительский элемент
B'
вместо B
и - он пропускает
somefile.ext
Как только вы сделали эту копию C'
из C
, у вас есть:
A--B--C--D <-- master
\
B'-C' <-- ??? (we'll get to this)
Теперь вы должны скопировать D
в D'
таким же образом:
A--B--C--D <-- master
\
B'-C'-D' <-- ??? (we'll get to this)
и теперь пришло время перейти к , какое имя ветви идет в вопросительных знаках проблема.
Очевидная вещь, которую нужно сделать, это очистить имя ветви master
от коммита D
и вместо него указать D'
:
A--B--C--D [abandoned]
\
B'-C'-D' <-- master
Любой, кто придет сейчаси просмотр этого хранилища начнется с name master
, чтобы получить хеш-код D'
.Они даже не заметят, что D'
имеет совершенно другой хэш-идентификатор, чем D
.Они посмотрят на D'
и вернутся к C'
, а оттуда к B'
, а затем обратно к A
.
Ну, почти любому.Что если придет еще один Git ?Что если этот другой Git уже имеет A-B-C-D
? Что Git имеет их и знает их по их хэш-идентификаторам.Хэш-идентификаторы являются универсальной валютой обмена Git.
Другие Git, которые могут прийти, - это любой клон, который вы сделали из исходного хранилища. Все клонов RepoA имеют оригинальные идентификаторы хэшей, перечисленные под собственным именем master
.Теперь вы должны убедить всех этих клонов переключить их master
с D
на новую замену D'
.
Если вы готовысделайте это - и они тоже - тогда у вас есть ответ: сделайте это с RepoA и заставьте всех переключатьсяЭто оставляет только необходимый механизм: как вы будете делать это с RepoA, и в этом отношении, как вы получите правильные коммиты в RepoC, если вы не сделаете это вручную?
git filter-branch
GiУ t есть встроенная команда, которая может сделать это: git filter-branch
. Команда filter-branch работает путем копирования коммитов. Логически (но не физически, за исключением самого медленного фильтра, --tree-filter
), то, что делает ветвь фильтра:
- проверить каждый коммит;
- применить ваши фильтры;
- map родительский хеш исходного коммита в соответствии с пока что картой; и
- построить новый коммит из отфильтрованного результата и ввести в карту.
Если новый коммит равен 100%, бит за битом идентичен оригинальному коммиту, то получается , являющийся исходным коммитом. Запись карты говорит, что коммит A
остается коммитом A
. Фильтр для фиксации B
вносит изменения - он удаляет файл. Таким образом, родительский элемент для следующего коммита A
(потому что A
отображается на A
), но новый коммит получает новый хэш-идентификатор B'
, и теперь карта говорит A
= A
но B
= B'
. Теперь происходит фильтр для C, удаляющий файл и делающий родителем нового коммита B'
, так что в результате получается новый коммит C'
, который входит в карту. Наконец, фильтр для D
происходит, делая новый коммит D'
с родителем C'
.
Теперь, когда все коммиты отфильтрованы, git filter-branch
использует встроенную карту для замены хеш-идентификатора, хранящегося в master
. На карте написано, что D
становится D'
, поэтому ветвь фильтра хранит хэш D'
под именем master
, и у нас есть то, что мы хотели.
Эту же технику можно использовать в RepoC. Помните, что RepoC является временным, где мы можем нанести любой ущерб, который нам нравится. Вместо удаления somefile.ext
в нашем фильтре мы хотим удалить все , кроме somefile.ext
. Мы также почти наверняка захотим аргумент --prune-empty
.
То, что делает --prune-empty
, достаточно просто описать. Давайте начнем с того, как все работает без --prune-empty
. В процессе копирования каждый исходный коммит копируется в новый. Это верно , даже если новый коммит, после применения фильтров, не вносит изменений . Если у нас есть коммит, подобный C
, который не трогает somefile.ext
, он, вероятно, затрагивает другие файлы. (Обычно Git не позволяет вам делать два коммита подряд с одинаковым содержимым - для этого нужно использовать git commit --allow-empty
.) Но если мы удалим все другие файлы ... хорошо, тогда у нас фактически есть B
и C
, являющиеся такими же , поэтому после того, как мы скопируем B
в B'
, чтобы иметь только somefile.ext
, мы будем скопируйте C
в C'
, чтобы иметь только somefile.ext
. Две копии будут совпадать. По умолчанию, ответвление фильтра в любом случае будет составлять C'
, так что C
есть что сопоставить.
Добавление --prune-empty
говорит Git: Не делайте C'
, просто сопоставьте C
с B'
. Когда мы делаем это, мы получаем именно то, что хотим: Git не делает t делает A'
вообще, делает B'
- что мы вместо этого называем I
- из B
с I
, имеющим нет родителя, не make C'
и делает D'
- что мы называем J
- из D
, используя B'
, er I
, в качестве родителя:
RepoC:
A--B--C--D [abandoned]
I-----J <-- master
Что осталось сделать
Осталось только выяснить, как написать фильтры для git filter-branch
. Вот о чем читают существующие ответы.
Простой фильтр для использования - --tree-filter
. Когда вы используете этот фильтр, Git запускает ваш фрагмент скрипта во временной директории. В этом временном каталоге находятся все файлы из коммита, которые фильтруются (но у него нет каталога .git
, и не ваше рабочее дерево!). Ваш фильтр просто должен изменить файлы на месте, или удалить некоторые файлы или добавить некоторые файлы. Git сделает новый коммит из того, что ваш фильтр оставляет во этом временном каталоге.
Это ятакже, безусловно, самый медленный фильтр. При использовании этого в большом хранилище будьте готовы подождать несколько часов или дней. (Это помогает использовать аргумент -d
, чтобы указать git filter-branch
на основанную на памяти «файловую систему», в которой можно выполнять всю свою работу, но она все еще очень медленная.) Поэтому большинство ответов сосредоточены на выяснении того, как Джиггер один из других, более быстрых фильтров для выполнения работы.
Вы можете работать с ними или использовать действительно медленный --tree-filter
. В любом случае, если вы используете filter-branch, вы теперь знаете, что делаете и почему.