Gộp hai repo thành monorepo: git log, blame và --follow còn thấy gì

So sánh git subtree add với filter-repo khi chuyển repo vào thư mục con, có số đo: lệnh xem lịch sử nào còn chạy, và commit id cũ ra sao.

· Đăng ngày · 6 phút đọc · Về dự án →

Gộp một repo frontend và một repo backend làm một chỉ mất vài lệnh. Thứ quyết định lịch sử có còn hay không không nằm ở lệnh merge, mà ở chỗ công cụ git nào còn nhìn thấy lịch sử đó sau khi gộp. Bài này trình bày hai cách mình biết để giữ lịch sử khi chuyển repo vào thư mục con: git subtree add, và viết lại đường dẫn bằng git filter-repo trước khi merge. Với mỗi cách, mình đo xem git log, --follow và git blame cho ra kết quả gì.

Dịp để làm việc này là hệ thống mua sắm cho bệnh viện: API và web app của nó bắt đầu từ năm 2024 dưới dạng hai repo riêng, và tháng 3/2026 mình chuyển cả hai vào một repo, thành backend/ và frontend/. Các số đo bên dưới lấy từ một thí nghiệm nhỏ có thể lặp lại với git 2.54, vì lịch sử của một repo thật quá rối để thấy rõ hành vi.

Cách subtree: hai lệnh

Bắt đầu với một repo có ít nhất một commit, rồi thêm từng repo cũ vào dưới tiền tố (prefix) của nó:

git init monorepo && cd monorepo
git commit --allow-empty -m "init repo"
git subtree add --prefix=backend  https://github.com/you/api.git main
git subtree add --prefix=frontend https://github.com/you/web.git main

git subtree add fetch repo kia về và tạo một commit merge có parent thứ hai là đầu nhánh của repo đó, với mọi file được chuyển vào dưới prefix. Trong repo của hệ thống bệnh viện, kết quả trông như sau:

Add 'backend/' from commit 'c9d7e0b7903e45961b1fca01480e288dbca23ce9'

git-subtree-dir: backend
git-subtree-mainline: ec5a5e1f40d81020ea32bf488bf581238aec05d1
git-subtree-split: c9d7e0b7903e45961b1fca01480e288dbca23ce9

Mọi commit cũ vẫn còn đó, với đúng id ban đầu. Các dòng trailer ghi lại commit nào đã được nhập vào, và đó là thông tin mà subtree pull và subtree split dùng về sau. Trong thí nghiệm, đồ thị commit đúng như mong đợi:

* 9d4c947 after move
*   832eec4 Add 'frontend/' from commit 'c66e091...'
|\
| * c66e091 web: first
*   99d6a46 Add 'backend/' from commit '7663a77...'
|\
| * 7663a77 api: second
| * 7e72647 api: first
* d3d8f4e mono: init

Đừng squash nếu bạn muốn giữ lịch sử

git subtree add có tuỳ chọn --squash. Với nó, toàn bộ lịch sử được nhập vào gộp thành một commit:

*   6ef6f4f Merge commit 'f55895c...' as 'backend'
|\
| * f55895c Squashed 'backend/' content from commit 7663a77
* 17ea334 init

Đó là lựa chọn đúng khi vendor một thư viện mà bạn chỉ cần một bản chụp. Khi chuyển chính dự án của mình, nó vứt đi đúng thứ mà việc chuyển nhằm giữ lại. Repo của hệ thống bệnh viện được thêm vào không dùng --squash, nên nó có hơn 500 commit kéo dài về tới năm 2024 thay vì vài commit từ năm 2026.

git log cho thấy gì sau khi subtree add

Đây là chỗ bất ngờ. Sau khi merge, mình sửa backend/src/server.ts thêm một lần rồi hỏi git lịch sử của file đó theo những cách hiển nhiên nhất:

Lệnh Commit hiện ra
git log -- backend/src/server.ts commit mới và commit merge của subtree
git log --follow -- backend/src/server.ts chỉ commit mới
git blame backend/src/server.ts mọi dòng đều gán đúng commit gốc, dưới đường dẫn cũ src/server.ts
git log -- src/server.ts không có gì
git log -- backend/src/server.ts src/server.ts cả bốn, cũ và mới
git log --full-history -- src/server.ts commit merge và hai commit cũ

git blame làm đúng; git log kèm đường dẫn thì không. Các commit cũ sửa src/server.ts, không phải backend/src/server.ts, và bộ lọc đường dẫn so khớp đường dẫn theo đúng từng chữ. Hỏi riêng đường dẫn cũ cũng không ra gì: tại commit merge, nhánh chính không có src/server.ts, nên cơ chế đơn giản hoá lịch sử mặc định của git đi theo parent thứ nhất và cắt bỏ phía được nhập vào. --full-history tắt cơ chế đó. --follow cũng không khá hơn trong thí nghiệm này, vì trên nhánh chính file không có tên cũ nào; việc đổi tên xảy ra ngay bên trong commit merge.

Đồ thị commit sau git subtree add: nhánh chính có mono: init, commit merge của subtree và after move; parent thứ hai của commit merge là phía được nhập vào với api: first và api: second, hai commit sửa src/server.ts. git log -- backend/src/server.ts chỉ thấy after move và commit merge; git blame, hoặc git log với cả hai đường dẫn, mới tới được các commit nhập vào.after movesửa backend/src/server.tsAdd 'backend/' (merge của subtree)chuyển file vào backend/api: secondsửa src/server.tsapi: firstsửa src/server.tsmono: initnhánh chính: không có src/server.tsgit log -- backend/src/server.tschỉ thấy hai commit nàygit blame, hoặc log với cả hai đường dẫnmới tới được các commit nhập vào
Các commit nhập vào sửa đường dẫn cũ, còn nhánh chính chưa bao giờ có đường dẫn đó, nên lọc theo đường dẫn mới dừng lại ở commit merge.

Trên thực tế, điều đó có nghĩa là:

  • Muốn biết ai viết một dòng và vì sao, git blame vẫn chạy như trước, và hiện đường dẫn cũ cạnh mỗi commit cũ.
  • Muốn xem toàn bộ log của một file, truyền cả hai đường dẫn: git log -- backend/src/x.ts src/x.ts.
  • Muốn chỉ xem dự án cũ, log phía của nó trong commit merge: git log 99d6a46^2 -- src/server.ts.

Trong repo của hệ thống bệnh viện, git blame backend/src/server.ts vẫn lần được tới những dòng viết từ tháng 3/2024.

Cách còn lại: viết lại đường dẫn rồi mới merge

Cách thay thế là làm cho lịch sử cũ trông như thể nó luôn nằm trong thư mục con, rồi merge phần đó vào. git-filter-repo làm việc viết lại chỉ bằng một tuỳ chọn:

git clone https://github.com/you/api.git api-moved
cd api-moved && git filter-repo --to-subdirectory-filter backend && cd ..

cd monorepo
git remote add api ../api-moved && git fetch api
git merge --allow-unrelated-histories api/main

Giờ mọi commit cũ đều sửa backend/src/server.ts, nên các lệnh thông thường chạy đúng:

Lệnh Commit hiện ra
git log -- backend/src/server.ts cả ba
git log --follow -- backend/src/server.ts cả ba

Cái giá là commit id. Viết lại cây file của một commit sẽ cho nó một id mới, nên 7663a77 trong repo gốc đã thành 782a497 trong thí nghiệm. Mọi link tới một commit cũ, trong issue, pull request, changelog hay commit message ("reverts 7663a77"), giờ trỏ tới một thứ không tồn tại trong repo mới.

Chọn cách nào

git subtree add filter-repo rồi merge
Commit id cũ Giữ nguyên Bị viết lại
git log -- path Dừng ở commit merge Đủ lịch sử
git log --follow Dừng ở commit merge Đủ lịch sử
git blame Đủ lịch sử Đủ lịch sử
Đường quay ra git subtree split tái tạo đúng id cũ Lại viết lại một lần nữa
Công cụ thêm Không cần, có sẵn trong git git-filter-repo

Hệ thống bệnh viện dùng subtree. Cái được là hai điều. Commit id cũ vẫn hợp lệ, nên bất cứ thứ gì nhắc tới một id, dù là issue, pull request hay commit message khác, vẫn trỏ đúng chỗ. Và việc chuyển vẫn đảo ngược được: trong thí nghiệm, git subtree split --prefix=backend sinh ra một nhánh mà các commit cũ giữ đúng id gốc, 7663a77 và 7e72647, với commit mới nằm trên cùng. Nếu một ngày backend cần lại repo riêng, đó chỉ là một lệnh.

Nếu sẽ không ai tra cứu commit cũ theo id, cách filter-repo cho ra một lịch sử mà công cụ nào cũng hiểu mà không cần tham số thêm. Cũng chính công cụ đó, dùng theo chiều ngược lại, đang giữ cho một bản fork của thư viện GPL đồng bộ với upstream.

Những gì khác cũng đổi khi đường dẫn đổi

Lịch sử không phải thứ duy nhất ngầm định thư mục gốc cũ. Sau khi chuyển, mọi thứ tham chiếu đường dẫn tính từ gốc repo đều cần thêm prefix:

  • Workflow CI: thư mục làm việc, khoá cache, bộ lọc đường dẫn quyết định job nào chạy khi file nào đổi.
  • Build Docker: build context và các đường dẫn trong COPY.
  • Thiết lập deploy trỏ tới một thư mục, chẳng hạn thư mục gốc của frontend trên nhà cung cấp hosting.
  • Cấu hình editor và công cụ ở gốc repo: cấu hình lint, mẫu trong .gitignore, pre-commit hook.

Không thứ nào trong số này hỏng lúc merge. Chúng hỏng ở lần build đầu tiên sau đó, nên nên làm việc chuyển trên một nhánh riêng và để CI chạy trước khi merge.

Tóm tắt

Mục tiêu Cách làm
Giữ lịch sử và commit id cũ git subtree add không kèm --squash
Biết ai viết một dòng sau khi chuyển git blame, vốn lần theo đường dẫn cũ
Xem đủ log của một file sau subtree add Truyền cả hai đường dẫn, hoặc log parent thứ hai của commit merge
git log và --follow chạy bình thường mọi nơi Viết lại bằng filter-repo --to-subdirectory-filter, chấp nhận id mới
Giữ đường quay ra git subtree split --prefix=...
Tránh build hỏng Chuyển trên một nhánh, cập nhật đường dẫn CI, Docker và deploy, để CI chạy

Nếu bạn từng gộp repo bằng một cách khác, mình rất muốn được trao đổi xem lịch sử của bạn giữ được tới đâu. Email của mình ở trang chủ.