![](/img/trans.png)
[英]Merge two git repositories with history into subdirs and pretend the files where always there
[英]Rewrite git history to merge two files
問題設置
我正在為一個大型 git 存儲庫做貢獻。 在某些時候,制作了許多文件的副本,然后在原始文件幸存下來的同時對其進行了編輯。 現在我想將復制文件(下面命名為new_file
)的修改包括回原始文件(命名為original
)。 問題是初始副本是使用普通cp
完成的,因此new_file
沒有original
的歷史記錄。
original -----A----x-------x-- D-----
new_file B------x-x-x---C
在上面的草圖中, new_file
已從original
提交A
復制(使用普通cp
,創建提交B
),然后多次編輯。 我知道如何使用git rm original && git mv new_file original
(commit D
) 將new_file
(commit C
) 復制到original
中,但這會丟棄original
或new_file
的行歷史記錄。
換句話說,我處在一個文件被分叉的情況下,我想將它合並回來,但是這個分叉並不是作為一個正確的 git-fork 完成的,而是作為一個文件副本完成的。
注意修改original
的分支不能修改,但是修改new_file
的分支可以。
我正在尋找的解決方案
我想重寫original
文件的 git 行歷史記錄,使其包含來自original
和new_file
的合並歷史記錄,就好像提交D
是包含original
文件的分支和包含對new_file
所做修改的另一個分支之間的合並。
您需要的是git merge-file
,並自己找到正確的合並基礎文件。 合並的結果可能很好,但您可能不喜歡此結果的某個方面。
在 Git 中確實沒有文件歷史記錄。Git 存儲庫中的歷史記錄由提交組成。 每個提交都包含其中的每個文件——或者更確切地說,Git 知道的每個文件,無論誰進行提交,都進行了該提交。
使用正常的git merge
,您可以通過簽出來選擇一個提交:
git checkout somebranch
這讓你得到當前“在”分支somebranch
上的最后一次提交,然后你運行:
git merge otherbranch
選擇當前“在”分支otherbranch
上的最后一次提交作為要合並的提交。 Git 然后使用作為歷史記錄的提交來查找兩個分支上的最佳共享提交,並使用來自該提交的文件作為合並基礎輸入。
注意,[原文件]被修改的分支不能被修改,但是[副本]被編輯的分支可以。
這個說法不太合理。 Git 中的分支名稱只是選擇了一些提交。 根據定義,它選擇的提交是鏈中的最后一個提交。 不能修改任何提交,甚至 Git 本身也不能。 git commit
所做的是進行新的提交。
也就是說,我們從提交鏈開始,其最后一次提交由分支名稱表示:
... <-F <-G <-H <-- branch (HEAD)
這里H
代表最后一次提交的實際 hash ID,不管它是什么。 我們已經檢查了分支branch
——這是附加(HEAD)
的意思——因此當前提交是提交H
。 提交H
導致回到更早的提交G
,這又導致回到F
,依此類推。
任何提交中的文件都不能更改。 它們都以一種特殊的、只讀的、僅 Git 的、凍結的和去重的格式存儲。 重復數據刪除處理了這樣一個事實,即H
中的大多數文件副本與G
中的大多數文件副本基本相同。 這些都是共享的(並且將來會與任何其他匹配的文件共享,無論您如何將匹配文件放入某個提交中)。 但是因為它們無法更改,甚至無法被非 Git 程序使用,所以 Git 將這些文件復制到您的工作樹中,以便您可以查看和處理它們。
當您進行新的提交時,Git 會在新的提交中存儲更新的文件——同樣是完整的副本,但同樣是去重的。 新的提交自動指向上一個鏈中的最后一個提交,Git 將新提交的 hash ID 寫入分支名稱:
... <-F <-G <-H <-I <-- branch (HEAD)
假設你的意思是你想做一個添加到某個分支的新提交,並且在這個新提交中,你想要一個名為original
的文件,其內容是一些三向合並過程的結果,這是你如何做到的:
找到當前文件的當前版本。 這很容易,就像在new_file
中一樣。
找到他們文件的另一個最新版本的當前內容,該文件名為original
。 這也很容易,因為它在他們分支的最后一次提交中是original
的。
(這是困難的部分,但看起來您已經這樣做了):找到兩個分支中相同的文件的任何版本。 您的描述暗示提交B
中名為new_file
的文件就是這樣一個文件。 它應該與提交A
中名為original
的文件相匹配:
git diff A:original B:new_file
應該什么都不顯示(用實際的 hash ID 替換A
和B
)。 因此,這兩個文件中的任何一個都足夠了。
將每個文件的文本提取到一個臨時區域(當前目錄中的三個單獨的文件就可以了,或者您可以將它們放在三個單獨的目錄中,或者您想要做的任何事情)。 當前版本的new_file
很簡單,因為您只需使用cp
即可:
cp new_file ours
當前版本的original
分支otherbranch
很容易通過git show
獲得:
git show otherbranch:original > theirs
該文件的基本版本需要知道提交A
或B
的 hash ID,但在其他方面與theirs
的相同:
git show B:new_file > base
例如。
運行git merge-file ours base theirs
(假設您使用了上面的名稱ours
、 base
和theirs
)。 git merge-file
命令將對這三個輸入文件執行單個低級合並,就好像您運行git merge
並且它選擇了B
(或您從中獲取基礎文件的任何地方)作為合並基礎一樣。
git merge-file
將把合並的最大努力結果放入名為ours
的文件中,給出上述命令。 如果存在合並沖突,您將必須像往常一樣手動解決它們,或者您可以使用--ours
、 --theirs
或--union
等同於-X ours
、 -X theirs
或不是的東西實際上可用在git merge
。 有關--union
的注意事項,請參閱git merge-file
文檔。
現在文件已合並,您只需將其重命名為您喜歡的任何名稱(例如original
),然后運行git add
將該文件復制到 Git 的索引中,在此過程中對其進行壓縮和去重,為下一個文件做好准備犯罪。 Git 的索引現在包含此文件的合並版本,因此git commit
現在會將此版本的文件保存在新快照中(連同當前在 Git 索引中的所有其他文件——您可以使用git ls-files --stage
詳細查看它們,盡管這很少有用,或者git status
將索引與HEAD
提交和您的工作樹進行比較)。
當你提交時,你得到的當然只是一個新的提交,帶有一組新的文件。 如果新提交同時包含original
和new_file
,則它包含兩個文件。 如果您在提交之前刪除new_file
(使用git rm new_file
),新提交將缺少new_file
但具有original
,其中包含您使用git add
寫入 Git 索引的內容。
當你有 Git 遍歷提交歷史時,你可以啟用 Git 的重命名檢測和/或復制檢測。 如果 Git 確定前一次提交的new_file
與新提交的original
足夠相似, 1它可能會將名為new_file
的文件(在舊提交中)標識為名為original
的文件(在新提交中)的源。 但這里並沒有保證,還有其他絆腳石:見腳注 1。
通常,Git 將新舊文件按名稱配對。 只有當一個文件在左側提交中丟失,並且一個具有不同名稱的新文件突然出現在右側提交中時,Git 才會打擾重命名檢測器。 即使那樣,您也必須將其打開。 它在 2.9 或更高版本的 Git 版本中默認打開,您可以在較早的版本中使用--find-renames
或diff.renames
配置設置打開它。
如果右側文件是新的並且您打開復制查找代碼,Git 也會將左側文件視為復制操作的源。 不過,您必須使用--find-copies-harder
選項使其將所有左側文件視為可能的來源。
不過,聽起來每個差異對的右側文件都不會是新的,所以這無濟於事,而且git log --follow
都不會打開它——所以git log --follow original
不會注意到重命名,並且不會嘗試在new_file
返回提交時嘗試跟隨它,一次一個,以向您顯示提交歷史記錄,或者提交歷史記錄被編輯為僅顯示“觸及”特定的特定提交文件與其父提交相比。
默認情況下,足夠相似需要 50% 或更高的“相似性指數”。 Git 的相似性計算相當模糊:它使用 packfile delta 壓縮器來識別文件的哪些部分匹配,哪些不匹配。 然后它將匹配大小除以整體大小。
( git diff
代碼有一個-B
選項來破壞配對,但聽起來它對您的情況根本沒有幫助。)
這是一個稍微不同的問題的不同答案。 假設我們有:
...--A--o--o--...--o <-- theirbranch
\
B--C--D--...--H <-- ourbranch
並且,回到提交B
,提交B
的人選擇將文件original
復制到文件new_file
,但我們現在認為這是一個錯誤:提交B
不僅應該保留名稱original
,所以應該通過H
提交C
包括在內.
雖然更改任何現有提交在物理上是不可能的,但我們——人類和我們對 Git 的指導——通過使用分支名稱來查找提交。 因此,如果我們可以將提交B
復制到一個新的和改進的提交B'
,而不是在H
之后進行新的提交來修復名稱,但給我們留下不滿意的歷史記錄怎么辦? 我們會有:
B' <-- improved
/
...--A--o--o--...--o <-- theirbranch
\
B--C--D--...--H <-- ourbranch
復制B'
(沿途進行了一些更改:具體來說,使用new_file
及其新內容,重命名為original
)以制作新的和改進B'
- 具有不同 hash ID 的不同提交,但具有相同的父A
和以前一樣——我們現在將現有提交C
復制到新的和改進C'
。 這一次,我們不僅將new_file
重命名為original
,還將新改進C'
的父級設置為B'
,這樣我們就有了:
B'-C' <-- improved
/
...--A--o--o--...--o <-- theirbranch
\
B--C--D--...--H <-- ourbranch
我們對提交D
重復此操作,依此類推,一直到我們分支上的最后一次提交,以便我們擁有:
B'-C'-D'-...--H' <-- improved
/
...--A--o--o--...--o <-- theirbranch
\
B--C--D--...--H <-- ourbranch
現在我們使用偷偷摸摸的技巧:我們將名稱ourbranch
從提交H
中拉出來,讓它指向提交H'
,這樣我們就有了:
B'-C'-D'-...--H' <-- improved, ourbranch
/
...--A--o--o--...--o <-- theirbranch
\
B--C--D--...--H [abandoned]
我們現在可以安全地完全刪除名稱improved
,並且,由於我們通過分支名稱找到提交,我們將不會再找到提交H
或通過B
返回的任何早期提交:
B'-C'-D'-...--H' <-- ourbranch
/
...--A--o--o--...--o <-- theirbranch
似乎不知何故,我們更改了ourbranch
上的每個提交。 我們沒有——我們所做的是更改ourbranch
上的整個提交集,而不是在每個提交中進行更改。 原件仍然存在,如果我們密切注意丑陋的 hash ID,我們可以看出這些提交( B'
到H'
)實際上是全新的提交,但誰會注意那些 hash ID ?
這是一個反問句,但它有一個答案: Git關注那些 hash ID。 因此,請確保您的 Git 指的是您的新提交,而不是舊提交,方法是確保每個選擇提交的名稱都會選擇其中一個新提交。 現在您已經在您的存儲庫中完成了此操作,您必須讓存儲庫的所有其他克隆(其他 Git)執行相同的操作。 這是重寫歷史的真正痛苦的部分:通常還有您的存儲庫的其他副本,並且它們必須在這里與您一起使用。
Git 為這種一系列提交的大量復制內置了兩個東西:
git rebase
,不是為這個工作設計的; 和git filter-branch
,但很難使用。 它還有一個工具,這是git rebase
主要使用的工具: git cherry-pick
復制單個提交。 如果您必須對每個副本進行復雜的更改,由坐在鍵盤前的人驅動,使用git cherry-pick
是到 go 的方法。(例如,可能必須手動檢查每個提交以查找對new_file
。)
假設您不必在復制每個提交時查看它,那么git filter-branch
將作為完成這項工作的工具。 因為它是如此笨拙——緩慢且難以使用——它現在正在積極地被git filter-repo
取代,但即使是當前版本的 Git 實際上也沒有附帶git filter-repo
。 因此,我將說明git filter-branch
的用法。
我們通常從復制存儲庫開始:
git clone <url>
git checkout ourbranch # use checkout's "DWIM mode" to create the branch
或類似的,因為過濾器分支出錯可能會非常混亂。 (一旦你對 Git 非常熟悉,從中恢復並不難,但通過處理副本,你可以避免那種沉沒的感覺:如果你破壞了副本,你只需將其刪除並重新開始。原版還是不錯的。)
現在我們運行git filter-branch -- er... uhm... what goes here?
現在我們遇到了一個問題,因為 filter-branch 有令人眼花繚亂的過濾選項。 使用哪一個取決於您。 每個都有不同的目的和不同的能力。 快的很難用; 慢的比較容易; --tree-filter
是最慢的之一,也是最簡單的之一。 在這種情況下--index-filter
可能是可用的,這是最快的之一。 要使用--index-filter
,我們只需要 Git 在 Git 的索引中重命名new_file
以使其命名為original
。 但是,如果我們需要對樹的rest做任何事情,那將變得非常困難。
我將在這里說明樹過濾器,因為它在概念上更簡單。 --tree-filter
的工作方式是這樣的:
對於要復制的每個提交,Git 將整個提交提取到一個工作區中。 它使用的工作區不是您的標准工作區。 它在某個臨時目錄中關閉。
然后,Git 運行您的過濾器。 您的過濾器可以是任何東西:一個 Python 程序、一個 shell 腳本、一個二進制文件,等等。 您的過濾器從臨時目錄運行,並且可以對該目錄中的所有文件執行任何它喜歡的操作。
當您的程序成功完成(返回零退出狀態)時,Git 檢查臨時目錄。 無論這里有什么文件,無論它們以什么形式存在,這些都是 go 復制到副本中的文件。 因此,您所做的任何更改都會顯示在副本中。 副本獲取原作者和committer姓名和email地址和日期等,並保留日志信息; 此處唯一發生變化的是新提交的 hash ID 和快照以及父 hash ID 由於任何較早的復制而需要。
因此,如果您需要做的只是重命名一個文件,那么您的--tree-filter
可以包含單個命令mv new_file original
。 這將重命名臨時目錄的new_file
副本並以成功狀態退出(因為該文件確實存在並且已成功重命名)。 如果您需要做更多的事情,您可以編寫一個程序來搜索特定文件以查找必須更改的new_file
的引用,然后更改它們。 您對任何臨時文件所做的所有更改都將 go 放入新提交中。 請注意,如果您的程序在臨時目錄中創建了備份文件,或者不小心刪除了任何文件,那么這些備份文件或刪除操作也會 go 進入新的提交!
現在我們有了合適的--tree-filter
程序或腳本或其他任何東西,我們需要選擇filter-branch
將復制的提交集,以及filter-branch
在完成所有操作后將移動的分支名稱復制。 這部分在過濾器和一個可選的(但你應該總是使用它)之后進行--
,在我們的例子中,我們只想復制可從ourbranch
訪問的提交,並且只復制提交A
之后的提交,因此我們將使用:
git filter-branch \
--tree-filter /tmp/fixup.sh \
-- \
A..ourbranch
為了發布目的,我將其分成四行,並假設樹過濾器腳本很復雜並且位於/tmp/fixup.sh
中(請注意,這必須是可執行文件和絕對路徑,因為過濾器-分支操作正在某些不可預測的臨時目錄中運行)。 第一行是 filter-branch 本身的調用,第二行是我們選擇的樹過濾器,第三行是我們應該使用的--
最后一行是提交A
的 hash 作為“停止”或否定引用,和字面名稱ourbranch
作為“開始”或正面參考。 因此,過濾器分支將:
git rev-list A..ourbranch
將列出的提交——事實上,filter-branch使用git rev-list
來獲取其 hash ID; 和ourbranch
的正引用來了解要調整的名稱。 這意味着我們可以在開始之前運行git log A..ourbranch
,以確保這實際上是要復制的正確提交集。 由於我們在存儲庫的副本中執行所有這些操作,因此從某種意義上說,如果我們弄錯了,它是“安全的”,但是由於 filter-branch 非常慢,弄錯了很煩人。
當 filter-branch 結束時,它會留下refs/original/refs/heads/ourbranch
。 如果過濾成功並且我們對更新后的克隆感到滿意,我們應該刪除剩余的名稱。
如果您有指向將被復制的提交的標記名稱,請注意這些標記名稱將指向原始提交。 要移動標簽名稱,您必須添加--tag-name-filter
。
(如果你可以安裝git filter-repo
並使用它,它會更快更方便,雖然你通常想知道 Python 用它來做任何花哨的事情。)
聲明:本站的技術帖子網頁,遵循CC BY-SA 4.0協議,如果您需要轉載,請注明本站網址或者原文地址。任何問題請咨詢:yoyou2525@163.com.