- Published on
- 9 phút đọc·
Git Submodules: Nhúng một repo vào trong một repo khác
Lần đầu mình đụng phải một Git submodule, mình còn chẳng biết đó là submodule.
Hôm đó mình clone project của một anh trong team về, chạy build, rồi nhìn nó fail vì thiếu một import. Thư mục thì nằm sờ sờ ngay trong cây source — libs/ui — nhưng nó trống rỗng. Không phải kiểu "mất thư mục", mà là có thư mục nhưng bên trong chẳng có gì. Gõ git status thì thấy nó hiện ra đúng một dòng xám xám, như kiểu Git biết có cái đó đấy nhưng nhất quyết không chịu điền nội dung vào.
Cái thư mục trống đó chính là toàn bộ câu chuyện về submodule, gói gọn trong một màn hình. Hiểu được vì sao nó trống thì mọi thứ còn lại về submodule tự nhiên hết bí ẩn. Nên mình sẽ đi từ vấn đề mà submodule giải quyết, rồi tới cách nghĩ cho đúng, cuối cùng mới tới mấy lệnh.
Vấn đề
Bạn có một mẩu code muốn dùng chung cho nhiều project — một thư viện UI, một repo config nội bộ, một client SDK. Bạn cần mọi project đều xài nó, nhưng cũng cần mỗi project tự quyết mình đang dùng version nào. Project A có thể chạy bản mới nhất, còn project B phải ghim ở commit của tháng trước vì có thứ khác đang phụ thuộc vào đó.
Mấy cách chữa cháy quen thuộc đều đau:
- Copy code vào từng project. Giờ sửa một con bug là phải sửa ở năm chỗ, rồi chúng âm thầm lệch nhau lúc nào không hay.
- Nhồi tất cả vào một repo khổng lồ. Nặng nề, mà nhiều khi bạn còn chẳng sở hữu cái code gốc đó.
Cái bạn thực sự muốn là: giữ thư viện đó ở một repo riêng, nhưng cắm được nó vào project và đóng băng ở đúng một version mình chọn.
Giải pháp: submodule
Đó đúng là khoảng trống mà submodule lấp vào. Nó cho phép bạn lồng một Git repository vào trong một repo khác, mà hai repo vẫn độc lập hoàn toàn.
Và đây là một câu giúp mọi thứ còn lại tự khớp vào chỗ: repo bên ngoài — gọi là superproject (mình hay gọi vui là "repo cha") — không copy repo bên trong. Nó chỉ lưu một con trỏ tới đúng một commit của repo đó.
Đó cũng là lý do libs/ui của mình trống trơn. Lệnh clone đã ghi lại trung thực rằng "thư mục này là repo UI ở commit a1b2c3d" — nhưng nó chưa hề kéo file thật về. Con trỏ có đó, còn nội dung thì chưa.
Thực ra Git lưu cái gì?
Khi bạn thêm một submodule, Git ghi hai thứ vào repo cha:
- File
.gitmodules(được commit hẳn hoi) — map mỗi đường dẫn với một remote URL. - Một gitlink — entry đặc biệt trong cây tree, lưu đúng commit SHA mà submodule sẽ nằm ở đó.
NOTE
Repo cha ghim theo một commit, không phải một branch. Nên khi bước vào trong submodule, bạn sẽ thấy nó ở trạng thái detached HEAD — trỏ thẳng vào một commit mà không có branch nào được checkout. Lần đầu nhìn thì hơi hoảng, nhưng đó đúng là cách submodule được thiết kế để chạy.
Dùng như thế nào?
Thêm một submodule
# thêm submodule vào đường dẫn "libs/ui"
git submodule add https://github.com/acme/ui libs/ui
git status lúc này hiện hai thứ cần commit — .gitmodules và libs/ui (cái gitlink). Commit cả hai:
git add .gitmodules libs/ui
git commit -m "Add ui as a submodule"
Clone một project có submodule
Quay lại cái thư mục trống của mình. Lệnh git clone bình thường ghi lại con trỏ nhưng để thư mục trống — bạn phải tự kéo nội dung về:
# clone và kéo luôn toàn bộ submodule trong một phát
git clone --recurse-submodules https://github.com/acme/app
# lỡ clone kiểu thường rồi? thì điền nội dung vào sau
git submodule update --init --recursive
Cập nhật một submodule
Đây là chỗ nhiều người rối nhất, vì có hai thứ hoàn toàn khác nhau mà người ta đều gọi là "update", trong khi chúng làm hai việc ngược nhau.
1. Đưa submodule về đúng commit mà repo cha đang mong đợi. Bạn làm việc này sau khi pull code của đồng đội về — họ đã dịch con trỏ, và bạn cần đuổi theo cho kịp:
git pull
git submodule update --init --recursive
2. Nâng submodule lên commit mới nhất trên remote của chính nó. Đây là khi chính bạn quyết định tiến lên:
git submodule update --remote libs/ui
Lệnh thứ hai làm thay đổi gitlink, nên giờ repo cha có một thay đổi cần commit:
git add libs/ui
git commit -m "Bump ui to latest"
Sửa code ngay trong submodule
Submodule là một repo đầy đủ, nên bạn hoàn toàn có thể vá bug ngay bên trong nó. Chỉ cần nhớ là nó đang ở detached HEAD — hãy checkout sang một branch trước đã, không thì commit của bạn thành mồ côi, sau này tìm lại khổ lắm.
cd libs/ui
git checkout main
# ... vá bug, rồi ...
git commit -am "Fix button padding"
git push # push lên remote của submodule TRƯỚC
cd ../..
git add libs/ui # ghi lại con trỏ commit mới
git commit -m "Update ui pointer"
git push
WARNING
Push submodule trước, repo cha sau. Làm ngược lại — push con trỏ mới của repo cha trong khi commit của submodule vẫn còn nằm trên máy bạn — thì ai pull về cũng dính fatal: reference is not a tree. Cái commit bạn trỏ tới đơn giản là chưa có trên remote. Hỏi sao mình biết hả? Mình từng làm cả team mất nguyên buổi sáng vì đúng cái này 😅.
Gỡ một submodule
Trạng thái của nó nằm rải ở ba chỗ (.gitmodules, .git/config và .git/modules), nên gỡ cho sạch cần vài bước:
git submodule deinit -f libs/ui
git rm -f libs/ui
rm -rf .git/modules/libs/ui
git commit -m "Remove ui submodule"
Một thiết lập nên bật
Gõ --recurse-submodules ở mọi lệnh thì nản lắm. Bật nó thành mặc định luôn:
git config --global submodule.recurse true
Giờ clone, pull, checkout đều tự bước vào submodule giúp bạn. Hai lệnh nữa mình dùng suốt:
git submodule foreach 'git switch main' # chạy một lệnh trong mọi submodule
git submodule status # xem SHA đang ghim của từng submodule
Được gì, mất gì
Cái hay:
- Tách bạch sạch sẽ. Mỗi repo giữ lịch sử riêng, không trộn lẫn vào nhau.
- Version chính xác, build lại y hệt. Repo cha luôn biết chính xác nó đang build trên commit nào, nên build hôm nay với build sang năm vẫn ra một kết quả.
- Tái sử dụng thật sự giữa nhiều project, không copy-paste một dòng.
Cái giá phải trả:
- Thêm bước ở khắp nơi. Clone hay update đều cần thêm một lệnh; quên là hỏng.
- Có đường cong học. Detached HEAD với quy tắc thứ tự push tóm được gần như tất cả mọi người, ít nhất một lần.
- Cần kỷ luật. Đổi version submodule là phải nhớ commit con trỏ mới ở repo cha — nó sẽ không nhắc bạn đâu.
Khi nào nên chọn cái khác?
Submodule chính xác nhưng tốn công bảo trì, nên trước khi quyết, hãy thử xem có công cụ nhẹ hơn nào hợp không:
- Package manager (npm, Maven, Go modules…) nếu repo bên trong chỉ là một dependency có version mà bạn xài chứ không sửa.
git subtreenếu bạn muốn gộp hẳn code vào và không quan tâm tới việc giữ lịch sử riêng của nó.- Monorepo nếu mấy project dính chặt với nhau và lúc nào cũng đổi cùng lúc.
Chỉ chọn submodule khi bạn thực sự cần một repo riêng, có lịch sử riêng, được ghim vào đúng một version bên trong một repo khác — và không có cách nào đơn giản hơn làm được việc đó.
Mấy cái bẫy hay gặp
| Hiện tượng | Nguyên nhân | Cách xử lý |
|---|---|---|
| Clone xong thư mục submodule trống | Quên --recurse-submodules | git submodule update --init --recursive |
reference is not a tree | Repo cha trỏ vào commit của submodule chưa push | Push submodule, hoặc trỏ lại commit đã có trên remote |
| Code vừa sửa trong submodule biến mất | Bạn commit khi đang ở detached HEAD | git checkout <branch> trước khi commit |
| Repo cha cứ báo submodule "modified" | Nó đang ở commit khác với gitlink | git submodule update để reset, hoặc git add + commit để chấp nhận |
Tóm lại
Giữ đúng một câu trong đầu là submodule hết đáng sợ: repo cha lưu một con trỏ tới commit, chứ không phải bản copy của code. Clone thì điền nội dung vào con trỏ, update thì dịch con trỏ, còn đổi thì commit lại SHA mới ở repo cha — sau khi đã push submodule. Còn cái thư mục trống mà mình hoảng hồn năm xưa? Giờ với mình nó chỉ là một con trỏ đang chờ được điền nội dung thôi.
Tham khảo
- Pro Git — Git Tools: Submodules — chương gốc, đào sâu nhất.
- Tài liệu lệnh
git submodule— đầy đủ mọi subcommand và flag, lấy thẳng từ docs. - Atlassian — Git submodule tutorial — giải thích rõ ràng, nhiều ví dụ.
- GitHub Blog — Working with submodules — workflow thực tế và mấy chỗ hay vấp.
Bài viết liên quan
Đăng ký nhận bản tin
Nếu bạn quan tâm, hãy đăng ký để nhận thông báo khi có bài viết mới nhé.
