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.
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 blamevẫ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ủ.