Published on
9 phút đọc·

Git Submodules: Nhúng một repo vào trong một repo khác

A submodule is a pointer, not a copy.
Bài viết này cũng có bản English.
banner

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 ở đó.
Repo cha lưu một con trỏ tới đúng một commit của submodule

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 — .gitmoduleslibs/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
git clone để trống submodule; --recurse-submodules thì kéo đầy đủ nội dung

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
Push submodule trước, rồi mới push repo cha

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.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

--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 subtree nế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ượngNguyên nhânCách xử lý
Clone xong thư mục submodule trốngQuên --recurse-submodulesgit submodule update --init --recursive
reference is not a treeRepo cha trỏ vào commit của submodule chưa pushPush submodule, hoặc trỏ lại commit đã có trên remote
Code vừa sửa trong submodule biến mấtBạn commit khi đang ở detached HEADgit checkout <branch> trước khi commit
Repo cha cứ báo submodule "modified"Nó đang ở commit khác với gitlinkgit 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

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é.

Authors
  • Bài trước

    Published on
    Distributed Lock và Cách Triển Khai với Redis
    Trong các hệ thống phân tán, việc đảm bảo tính nhất quán của dữ liệu (data consistency) và ngăn chặn tranh chấp tài nguyên (race condition) là một thách thức lớn, đặc biệt khi nhiều tiến trình hoặc service truy cập đồng thời vào các tài nguyên dùng chung. Distributed lock là giải pháp hiệu quả để xử lý vấn đề này. Bài viết này mình sẽ giúp bạn hiểu rõ distributed lock là gì, tại sao nó cần thiết, các phương pháp để thực hiện và cách triển khai nó với Redis nhé.