Chuyển đến nội dung chính

Giải thích Git Subtree: Hướng dẫn thực hành kèm ví dụ

Dùng bốn lệnh thiết yếu (add, pull, push, split) để giữ đồng bộ thư viện dùng chung giữa các nhóm.
· 12 phút đọc

Khám phá cùng AI

Bạn đang làm một dự án phân tích dữ liệu và cần đưa vào một thư viện tiện ích dùng chung do nhóm của bạn duy trì ở một repository khác. Bạn có thể sao chép-dán mã, nhưng như vậy sẽ mất đường cập nhật. Bạn có thể dùng Git submodule, nhưng bạn nghe nói nó phức tạp. Có một lựa chọn thứ ba: git subtree.

git subtree cho phép bạn nhúng một repository Git vào bên trong repository khác như một thư mục con, giữ nguyên đầy đủ lịch sử commit và cách cập nhật dễ dàng.

Hướng dẫn này đề cập khi nào nên dùng git subtree, cách nó hoạt động, và các ví dụ thực hành cho những quy trình làm việc phổ biến. Chúng ta sẽ dùng các tình huống thực tế bạn thường gặp trong dự án khoa học dữ liệu.

git subtree là gì?

Git subtree đưa một repository Git khác vào dưới một thư mục cụ thể trong dự án của bạn đồng thời giữ toàn bộ lịch sử commit của nó. Không giống việc sao chép mã hoặc dùng liên kết tượng trưng, subtree duy trì kết nối với repository gốc để bạn có thể kéo cập nhật và đẩy thay đổi ngược trở lại.

Điểm khác biệt so với chỉ sao chép mã:

Sao chép-dán mã:

  • Không có kết nối với repo gốc
  • Không có đường cập nhật khi thư viện thay đổi
  • Không có lịch sử nguồn gốc của mã

Git subtree:

  • Đầy đủ lịch sử commit từ repo gốc
  • Kéo cập nhật bằng một lệnh duy nhất
  • Đẩy thay đổi trở lại repo gốc nếu cần
  • Mọi thứ nằm trong một lần checkout repository

Khác biệt then chốt với Git submodule: nội dung subtree được commit trực tiếp vào repository chính của bạn. Khi ai đó clone dự án của bạn, họ nhận được mọi thứ trong một lần checkout, không cần bước thiết lập bổ sung.

Tất cả điều này dẫn đến việc gia nhập dự án dễ hơn và ít gặp lỗi kiểu “chỉ chạy được trên máy tôi”.

Cách git subtree hoạt động

Khi bạn thêm một subtree, Git làm một việc thông minh: nó trộn lịch sử của repository khác vào một thư mục con trong dự án của bạn. Cụ thể như sau:

  1. Git fetch lịch sử của repository từ xa
  2. Git viết lại các commit đó để chúng xuất hiện dưới thư mục con bạn chọn
  3. Git merge lịch sử đã viết lại này vào nhánh hiện tại của bạn
  4. Các lần cập nhật sau theo cùng mô hình: fetch, viết lại, merge

Repository của bạn chứa các tệp thực tế và toàn bộ lịch sử commit từ subtree, không chỉ là một con trỏ hay tham chiếu. Khi ai đó clone dự án của bạn hoặc checkout một nhánh, họ có ngay toàn bộ mã. Không có bước “khởi tạo submodule” riêng.

Đánh đổi: repository của bạn sẽ lớn hơn vì chứa cả mã của bạn và mã của subtree cùng toàn bộ lịch sử của nó. Có đáng không? Tùy tình huống, nhưng với hầu hết các nhóm khoa học dữ liệu, tôi cho là đáng.

Cách dùng git subtree (Các lệnh phổ biến)

Giờ bạn đã hiểu subtree làm gì, hãy đi qua những thao tác phổ biến nhất. Chúng ta sẽ dùng một kịch bản thực tế: bạn đang xây dựng dự án phân tích dữ liệu và cần đưa vào một thư viện tiện ích dùng chung.

Thiết lập ví dụ:

  • Dự án chính: data-pipeline

  • Thư viện dùng chung: shared-utils (tồn tại tại https://github.com/yourteam/shared-utils.git)

  • Bạn muốn đưa nó vào dưới libs/shared-utils/ trong dự án của bạn

git subtree add

Lệnh add đưa một repository khác vào dự án của bạn lần đầu tiên.

Cú pháp cơ bản để tham khảo như sau:

git subtree add --prefix=<directory> <remote-url> <branch> --squash

Ví dụ thực tế:

# Add the shared-utils repo under libs/shared-utils
git subtree add --prefix=libs/shared-utils \
  https://github.com/yourteam/shared-utils.git \
  main \
  --squash

Lệnh này làm gì:

  • Tạo thư mục libs/shared-utils
  • Sao chép tất cả tệp từ shared-utils vào đó
  • Commit mọi thứ vào repository của bạn
  • Ghi nhận kết nối để cập nhật về sau

Hiểu về các cờ:

  • --prefix=libs/shared-utils: Vị trí subtree nằm trong dự án của bạn. Bạn phải dùng chính xác cùng một prefix cho mọi thao tác sau này. Thay đổi nó một lần và bạn sẽ phải cặm cụi tìm trên Stack Overflow lúc 2 giờ sáng để hiểu vì sao mọi thứ hỏng.

  • --squash: Gộp toàn bộ lịch sử commit của subtree thành một commit duy nhất trong dự án của bạn. Điều này giúp lịch sử dự án sạch hơn. Không dùng --squash, bạn sẽ thấy mọi commit từ repo gốc trong lịch sử dự án, có thể gây ngộp.

Khi nào nên squash: Hãy dùng --squash trừ khi bạn cần giữ nguyên chi tiết quy gán commit từ repo gốc. Với hầu hết dự án dữ liệu có thư viện dùng chung, lịch sử đã squash sạch và dễ làm việc hơn. Tôi luôn dùng nó.

Sau khi chạy, bạn sẽ thấy một commit mới trong dự án với thông điệp như "Add 'libs/shared-utils/' from commit 'abc123'". Thư mục libs/shared-utils giờ chứa toàn bộ tệp và bạn có thể dùng ngay.

git subtree pull

Lệnh pull cập nhật subtree của bạn với thay đổi từ repository gốc. Nếu nhóm thư viện dùng chung sửa lỗi hoặc thêm tính năng, đây là cách bạn nhận các cập nhật đó.

Cú pháp cơ bản:

git subtree pull --prefix=<directory> <remote-url> <branch> --squash

Ví dụ thực tế:

# Pull latest changes from shared-utils
git subtree pull --prefix=libs/shared-utils \
  https://github.com/yourteam/shared-utils.git \
  main \
  --squash

Lệnh này làm gì:

  • Fetch các commit mới từ repository từ xa
  • Merge chúng vào thư mục libs/shared-utils của bạn
  • Tạo một merge commit trong dự án của bạn

Prefix phải khớp chính xác. Nếu bạn dùng libs/shared-utils lúc add, bạn phải dùng libs/shared-utils cho pull. Dùng prefix khác sẽ không hoạt động. Tin tôi đi.

Tính nhất quán khi squash: Nếu bạn dùng --squash lúc add, bạn nên dùng --squash cho mọi lần pull. Trộn giữa cập nhật đã squash và chưa squash sẽ tạo lịch sử khó hiểu. Tôi đã rút kinh nghiệm đắt giá.

Xử lý xung đột: Nếu bạn đã chỉnh sửa tệp trong libs/shared-utils và upstream cũng thay đổi các tệp đó, bạn sẽ gặp xung đột merge. Giải quyết như mọi xung đột Git khác — chỉnh sửa tệp, stage chúng, và hoàn tất merge.

Hãy ghi vào README của nhóm liệu bạn có dùng --squash hay không, để mọi người thống nhất.

git subtree push

Lệnh push gửi các thay đổi bạn đã thực hiện trong thư mục subtree quay lại repository gốc.

Cú pháp cơ bản:

git subtree push --prefix=<directory> <remote-url> <branch>

Ví dụ thực tế:

# Push changes from libs/shared-utils back to the original repo
git subtree push --prefix=libs/shared-utils \
  https://github.com/yourteam/shared-utils.git \
  main

Lệnh này làm gì:

  • Trích xuất chỉ các commit ảnh hưởng tới libs/shared-utils
  • Viết lại chúng như thể được thực hiện ở thư mục gốc của shared-utils
  • Đẩy các commit đó lên repository gốc

Khi nào dùng: Bạn đang bảo trì một thư viện dùng chung bên trong repository ứng dụng và muốn đóng góp cải tiến ngược trở lại. Ví dụ bạn thêm một hàm kiểm tra dữ liệu hữu ích vào shared-utils khi làm dự án data-pipeline, và các dự án khác cũng nên hưởng lợi.

Hãy coi push là chiều ngược của pull. Pull mang thay đổi vào; push gửi thay đổi ra. Git chỉ trích xuất lịch sử liên quan và chuyển dịch nó trở lại cấu trúc của repository gốc. Khá thông minh.

Bạn cần quyền ghi vào repository gốc. Nếu không có, bạn cần fork repo, đẩy lên fork của mình, và tạo pull request.

git subtree split

Lệnh split trích xuất lịch sử của một thư mục con thành một nhánh riêng.

Cú pháp cơ bản:

git subtree split --prefix=<directory> -b <new-branch-name>

Ví dụ thực tế:

# Extract libs/shared-utils into its own branch
git subtree split --prefix=libs/shared-utils -b shared-utils-extracted

Lệnh này làm gì:

  • Tạo một nhánh mới chỉ chứa các commit ảnh hưởng tới libs/shared-utils
  • Nhánh này trông như một repository độc lập chỉ gồm thư mục đó
  • Nhánh gốc không bị thay đổi

Các trường hợp sử dụng phổ biến:

Tách một thư viện khỏi monorepo: Pipeline xử lý dữ liệu của bạn đã phình to, và bạn muốn tách một thành phần có thể tái sử dụng thành thư viện độc lập. Split tạo một nhánh chỉ với lịch sử của thành phần đó, bạn có thể đẩy lên một repository mới.

Phát hành một thư mục con như dự án riêng: Bạn đã xây dựng một mô-đun trực quan hóa dữ liệu hữu ích trong dự án phân tích và muốn chia sẻ như gói độc lập. Split trích xuất nó với lịch sử đầy đủ còn nguyên vẹn.

Quy trình ví dụ tạo một repo độc lập mới:

# Extract the subdirectory
git subtree split --prefix=libs/shared-utils -b shared-utils-standalone

# Create a new repo and push to it
git remote add shared-utils-origin https://github.com/yourteam/shared-utils-new.git
git push shared-utils-origin shared-utils-standalone:main

Giờ shared-utils-new là một repository hoàn chỉnh với đầy đủ lịch sử chỉ của thư mục con đó.

git subtree so với submodule

Đây là điều mọi người luôn hỏi: subtree so với submodule.

Cả hai đều cho phép bạn đưa mã bên ngoài vào repository của mình, nhưng có khác biệt. Trường hợp bạn cần ghim phiên bản chính xác, hoặc nếu kích thước repo bị ràng buộc, hãy dùng submodule. Nếu đội của bạn có mức độ thành thạo Git không đồng đều và bạn muốn sự đơn giản, hãy dùng subtree. Còn lại, cái nào cũng được.

Khía cạnh

git subtree

git submodule

Độ phức tạp khi thiết lập

Trung bình (các lệnh rõ ràng)

Cao (cần bước init riêng)

Trải nghiệm khi clone

Đơn giản (một lần clone, mọi thứ hoạt động)

Phức tạp (clone + git submodule update --init)

Kích thước repository

Lớn hơn (gồm toàn bộ nội dung + lịch sử của subtree)

Nhỏ hơn (chỉ là con trỏ tới repo khác)

Gia nhập nhà phát triển

Dễ (mọi thứ hoạt động sau khi clone)

Khó hơn (phải hiểu quy trình submodule)

Độ phức tạp CI/CD

Đơn giản (clone là chạy)

Phức tạp hơn (phải khởi tạo submodule)

Ghim phiên bản

Kém chính xác hơn (merge commit mới nhất hoặc commit cụ thể)

Chính xác (hash commit cụ thể)

Quy trình cập nhật

git subtree pull (merge thay đổi)

cd submodule && git pull (thủ công)

Rủi ro "chỉ chạy trên máy tôi"

Thấp hơn (mọi thứ đã được commit)

Cao hơn (trạng thái submodule có thể lệch)

Đẩy thay đổi ngược lại

git subtree push

Git push bình thường bên trong submodule

Phân tách trách nhiệm

Pha trộn (mã nằm trong repo chính)

Rõ ràng (submodule là repo riêng)

Dùng git submodule khi bạn cần:

  • Ghim phiên bản nghiêm ngặt (phải dùng đúng commit X của thư viện Y)
  • Phân tách rõ ràng giữa các dự án (chúng thực sự độc lập)
  • Nhiều dự án dùng chung cùng phụ thuộc ở các phiên bản khác nhau
  • Kích thước repository chính nhỏ là yếu tố quan trọng trong quy trình của bạn

Dùng git subtree khi bạn muốn:

  • Không cần bước khởi tạo bổ sung
  • Thành viên mới chỉ cần clone một lần rồi chạy kiểm thử
  • Ít khái niệm Git hơn để nhóm bạn phải học
  • Vendor hóa phụ thuộc với việc thỉnh thoảng kéo cập nhật

Không có lựa chọn nào luôn tốt hơn. Quyết định phụ thuộc vào việc bạn coi trọng sự đơn giản hay kiểm soát phiên bản nghiêm ngặt. Quan điểm của tôi: Với các nhóm khoa học dữ liệu nơi đa số tập trung vào phân tích hơn là nội tình Git, subtree thường giảm ma sát. Với các nhóm nền tảng quản lý dịch vụ có yêu cầu về phụ thuộc, submodule là hợp lý.

Khi không nên dùng git subtree

Đừng dùng git subtree khi kích thước repository là ràng buộc cứng. Subtree làm phình kích thước repo vì bao gồm toàn bộ nội dung và lịch sử. Nếu bạn gần chạm giới hạn của nền tảng Git hoặc tốc độ mạng rất quan trọng, submodule nhẹ hơn.

Cũng đừng dùng git subtree nếu bạn cần ghim phiên bản nghiêm ngặt. Bạn phải dùng chính xác phiên bản 2.3.1 của thư viện và không gì khác. Submodule ghim vào commit cụ thể; subtree merge các dải commit.

Đừng dùng git subtree nếu repo bên ngoài thay đổi thường xuyên. Subtree được cập nhật hàng ngày với thay đổi lớn. Liên tục kéo cập nhật sẽ tạo lịch sử merge lộn xộn. Hãy cân nhắc liệu bạn thực sự cần nhúng nó hay trình quản lý gói sẽ sạch hơn.

Cuối cùng, đừng dùng git subtree nếu cần tách bạch về mặt tổ chức. Nội dung subtree có kiểm soát truy cập, giấy phép, hoặc quyền sở hữu khác biệt. Giữ chúng trong các repository riêng sẽ làm ranh giới rõ ràng hơn.

Ưu điểm của git subtree

Trải nghiệm clone một repository duy nhất: Chạy git clone là xong. Mọi thứ bạn cần đều có. Điều này giảm ma sát khi gia nhập nhóm, nhất là với những người chưa rành Git.

Ít thành phần chuyển động hơn so với submodule: Không có bước khởi tạo riêng. Không có trạng thái HEAD tách rời khó giải thích. Không có nhầm lẫn “submodule của bạn không đồng bộ”. Quy trình làm việc gần với các thao tác Git tiêu chuẩn (add, pull, push).

Đơn giản cho CI/CD: Script tích hợp liên tục của bạn đơn giản hơn. Với subtree, bạn clone và chạy kiểm thử. Với submodule, bạn clone, khởi tạo đệ quy, rồi mới chạy kiểm thử. Bước thêm đó dễ làm hỏng build khi bị quên.

Hợp với đội có mức độ thoải mái với Git khác nhau: Thành viên trẻ trong lĩnh vực dữ liệu không cần hiểu cơ chế submodule. Họ dùng các lệnh Git quen thuộc và mọi thứ hoạt động.

Phù hợp để vendor hóa với cập nhật thỉnh thoảng: Bạn đưa vào phiên bản 1.2 của một thư viện. Sáu tháng sau, phiên bản 1.3 có bản vá bạn cần. Kéo về bằng một lệnh. Bạn không bị mắc kẹt với mã sao chép không được duy trì, nhưng cũng không liên tục bám theo thay đổi upstream.

Hạn chế và đánh đổi của git subtree

Repository tăng đáng kể: Repo của bạn chứa cả mã của bạn và mã của subtree cùng toàn bộ lịch sử commit (trừ khi bạn dùng --squash, sẽ đỡ hơn). Một subtree 50MB sẽ cộng thêm khoảng 50MB vào kích thước repo của bạn.

Với subtree lớn hoặc nhiều subtree, con số này sẽ cộng dồn. Hãy cân nhắc xem sự tiện lợi có xứng với dung lượng đĩa và thời gian clone hay không.

  • Đồ thị lịch sử phức tạp: Dù dùng --squash, các merge commit cho cập nhật subtree vẫn làm lịch sử Git phức tạp hơn. Nếu bạn thường xuyên kéo cập nhật subtree, đồ thị commit sẽ rối.

  • Nhầm lẫn khi phân kỳ so với upstream: Nếu bạn sửa tệp trong subtree và upstream cũng thay đổi chúng, sẽ phát sinh xung đột merge. Giải quyết xung đột trong subtree có thể gây bối rối vì bạn đang merge repo của người khác vào một thư mục con của mình.

  • Quy trình push có điểm cần lưu ý: git subtree push cần viết lại lịch sử, có thể chậm với subtree lớn. Nếu bạn có nhiều commit trong thư mục subtree, push có thể mất thời gian. Hãy kiên nhẫn.

  • Xung đột merge vẫn xảy ra: Nếu bạn và upstream cùng chỉnh sửa một tệp, bạn sẽ phải giải quyết xung đột. Đây không phải vấn đề riêng của subtree, và subtree không thể “thần kỳ” ngăn xung đột Git.

  • Cần tính kỷ luật: Dùng các giá trị --prefix khác nhau hoặc cờ --squash không nhất quán sẽ gây vấn đề. Nhóm cần ghi chép và tuân thủ cùng một quy trình.

Những hạn chế này không phải rào cản không vượt qua được, nhưng đáng để biết trước. Đánh đổi là: quy trình đơn giản hơn đổi lại kích thước repo lớn hơn và lịch sử có thể phức tạp.

Lỗi thường gặp khi dùng git subtree

Khi bắt đầu dùng git subtree, hãy chú ý các vấn đề thường gặp dưới đây. Chúng rất dễ tránh khi bạn biết trước.

Không nhất quán khi dùng --squash

Bạn dùng --squash khi thêm subtree nhưng quên dùng khi git subtree pull. Giờ lịch sử của bạn có cả commit đã squash và chưa squash từ subtree, gây khó hiểu.

Cách khắc phục là ghi rõ trong README của dự án. Viết ghi chú kiểu "Luôn dùng --squash với subtree shared-utils."

Sai hoặc thay đổi prefix

Bạn đã thêm subtree với --prefix=libs/shared-utils nhưng sau đó lại cố pull với --prefix=libs/utils. Điều này không hoạt động vì Git không thể tìm thấy subtree.

Cách khắc phục là luôn dùng đúng cùng một prefix. Cân nhắc ghi vào script, như sau:

# scripts/update-subtree.sh
#!/bin/bash
git subtree pull --prefix=libs/shared-utils \
  https://github.com/yourteam/shared-utils.git \
  main \
  --squash

Kỳ vọng subtree hoạt động như submodule

Bạn nghĩ có thể cd libs/shared-utils && git checkout để chuyển phiên bản. Điều đó không hoạt động vì nội dung subtree chỉ là các tệp trong repo của bạn, không phải một repository Git riêng.

Cách khắc phục là hiểu rằng subtree merge các commit cụ thể. Để “hạ cấp” một subtree, bạn cần tìm và merge một commit cũ hơn hoặc revert các commit cập nhật subtree.

Không ghi chép quy trình làm việc

Thành viên trong nhóm không biết có nên dùng --squash, dùng URL remote nào, hay khi nào đẩy thay đổi ngược lại. Nếu ai cũng làm khác nhau, lịch sử sẽ thiếu nhất quán.

Cách khắc phục là thêm một mục vào README:

## Updating shared-utils

## To pull latest changes:
git subtree pull --prefix=libs/shared-utils https://github.com/yourteam/shared-utils.git main --squash

## To push changes back:
git subtree push --prefix=libs/shared-utils https://github.com/yourteam/shared-utils.git main

Cuối cùng, chỉnh sửa tệp subtree mà không có kế hoạch

Bạn chỉnh sửa các tệp trong libs/shared-utils cho dự án cụ thể của bạn, nhưng không bao giờ đẩy ngược. Sáu tháng sau, bạn pull cập nhật và gặp xung đột vì thay đổi của bạn và upstream đụng nhau.

Cách khắc phục là: Nếu bạn chỉnh sửa các tệp subtree, hoặc đẩy thay đổi ngược về shared-utils nếu chúng hữu ích cho mọi người, hoặc chấp nhận rằng bạn sẽ cần xử lý xung đột trong các lần cập nhật.

Kết luận

Git subtree đánh đổi kích thước repository và độ phức tạp lịch sử để lấy quy trình làm việc đơn giản. Với các nhóm khoa học dữ liệu nơi đa số tập trung vào phân tích hơn là nội tình Git, subtree giúp giảm ma sát. Thành viên mới clone repo và bắt tay vào làm ngay.

Để học thêm về các quy trình Git, hãy khám phá khóa học Introduction to Git của chúng tôi.

Oluseye Jeremiah

Tác giả

Oluseye Jeremiah

Nhà viết nội dung về công nghệ, chuyên về AI, ML và khoa học dữ liệu, giúp làm rõ và phổ biến những ý tưởng phức tạp.

Chủ đề

Liên quan