如何用 Git 合併兩個庫(合併歷史記錄,解決衝突/改寫路徑)

n͛i͛g͛h͛t͛i͛r͛e͛發表於2014-09-18

本來已經不寫文字部落格了,一般心得都錄成了視訊(這在我看來是更好的方式),但是今天遇到一個關於 Git 的問題不太好重現也不便於錄製視訊,加上它本身很具有代表性也很有用,所以還是記錄於此。

背景

一箇中型規模專案,開始規劃時就打算採用 C/S 架構,後端是單純的 API 服務,前端在 Web 上搞一個 SPA,之後再搞其他端也就順理成章了。只可以第一次弄沒經驗,有些細節最初沒有考慮到。

建立專案的時候前後端真是完全分離的,分成了兩個目錄,建立了兩個 repos。一開始只有一個人乾的時候倒也沒什麼,開兩個視窗切來切去也就罷了,後來一是部署起來麻煩,二來主要是其他開發者加入後,程式碼的版本管理、提交、合併、稽核等等等等都變得越來越繁瑣。

後來一想:架構上分離而已,幹嘛非要兩個目錄兩個 repos?真是自找麻煩!於是就開始考慮整合。

要求

把兩個目錄併成一個倒不難,但是要完整保留雙方的歷史記錄就有些麻煩了,這也是唯一一個必須要實現的目標。

過程

首先為了便於描述,約定整合前兩個目錄分別叫做 frontendbackend,合併後的結構與名稱應當如下:

- project/                  => 即最開始的 frontend,整合完後更名
  - .gitignore              => 合併兩個 repos 的忽略檔案
  - .git/                   => 最終僅餘一個 repo
  + client/                 => 對應 frontend
  + server/                 => 對應 backend

以下步驟是以 frontend 為基點,把 backend 移進來,實際上反過來也是一樣的,自行替換對應的名稱即可。在開始之前先清理兩個 repos 裡的工作記錄,該提交的提交,該備份的備份,保持乾淨。

1. $ [~] cd frontend
2. $ [frontend] git remote add -f backend /fullpath/to/backend
3. $ [frontend] git merge --strategy ours --no-commit backend/master
4. $ [frontend] mkdir -p server
5. $ [frontend] git read-tree --prefix=server/ -u backend/master
6. $ [frontend] git commit --message `完成 backend 的遷移,新目錄為 server`
7. $ [frontend] mkdir -p client
8. # 拷貝 frontend 的原始專案檔案(除了 .git/ 和 .gitignore 以外)至 client/
9. $ [frontend] cd ..; mv frontend/ project/; cd project
10. $ [project] cat server/.gitignore >> .gitignore
11. # 整理合並後的 .gitignore,修復其中的路徑缺失並儲存;修復各種專案依賴的缺失,本地測試。
12. $ [project] git add --all; git commit --message `遷移整合完成!`

以上是完整的步驟先列出來方便參考,下面做一個詳細的解釋。

整個過程中主要用到的工具是 mergeread-tree,前者用於合併歷史記錄並且中斷在最後提交之前,所產生的檔案衝突不會被寫入硬碟;然後利用後者重寫整個檔案樹並把讀取到的內容(讀取的目標是 backend)寫入新的路徑下。最後提交以結束合併。

2步裡,我們把 backend 作為 remote server 新增到 frontend 庫中。-f 的作用是在新增後立刻 fetch。要注意一定得使用絕對路徑來引用 backend 庫。

3步裡,--strategy ours 比較難以理解,且聽我詳細道來:一般來說當合並兩個檔案樹時,如果遇到衝突我們是需要手動去解決它的,但是目前我們要做的不是解決衝突,而是在引入 backend 歷史記錄的前提下完整保留 frontend 的內容。衝突肯定是會有的,即使兩個不同的專案也是如此,比方說兩邊都有 README.mdapp/config/ 等檔案或目錄,但是我們不關心衝突,我們只要保留 frontend 的檔案樹並且把 backend 的歷史記錄合併進來。

--strategy ours 會完成全部的合併解析,但是所有的衝突都以“我”為準,不允許外來的衝突覆蓋“我”的檔案內容。最終的結果就是:

  1. backend 的歷史記錄被合併到 frontend 的歷史記錄中
  2. backend 的檔案樹被讀取並和 frontend 的檔案樹比對進行衝突解析:
    • 如果發現衝突,以 frontend 為準,丟棄所有內容變更
    • 沒衝突的則保留(但是我們也不要的,見後面的內容)

這也是後面緊接著使用 --no-commit 的原因,該選項會在合併解析完成後中斷,停留在最後的提交步驟之前。我們知道,只要你還沒 commit,那麼 merge 的結果就暫時儲存在快取區中,只有完成提交步驟合併才算徹底完成(檔案樹被正式改變)。這就給我們一個機會來重新讀取 backend 的檔案樹,並改寫其儲存的位置。不過在此之前,第4步先要建立目標子目錄(很重要!)。

5步開始 read-tree 了,--prefix 用於指定檔案樹讀取後儲存的路徑,相對於當前路徑並且一定要追加 /-u 是說在讀取後更新 index,使得 working treeindex 保持同步。如果你不小心忘了加 -u,可以在這一步之後執行 git add --update,一樣的效果。

這一步在背後有些細節比較抽象,之前的 merge 也曾讀取過 backend 的檔案樹,但經過沖突解析之後已經面目全非,分析如下:

  • 有衝突的被丟棄,因此一部分檔案/目錄其實已經不存在了
  • 沒衝突的被保留,但是路徑還在 frontend 的根路徑下

經過再次 read-tree,上面的“遺蹟”得以修復,結果如下:

  • 有衝突的因為已被丟棄,所以直接從本次讀取中獲得,且路徑前面追加 --prefix 選項的值
  • 沒衝突的雖然被保留,但是由於本次讀取追加了 prefix,所以它們的路徑也被改變,相當於在快取裡做了一次 git mv

好了,重點就是這些,之後的步驟都很尋常,只要小心操作就沒什麼難理解的。

相關文章