SSD Nodes Learn Hosting plans →
Hướng dẫn Matt ConnorBởi Matt Connor

Cách fork repo GitHub và giữ fork đồng bộ với upstream

Phân biệt fork, clone và branch, rồi fork repo trên GitHub, thêm remote upstream, fetch và merge để fork không bị tụt hậu, rồi mở pull request về repo gốc.

Fork, clone và branch khác nhau ở chỗ nào

Fork một repo GitHub là tạo một bản sao của repo đó ngay trên tài khoản GitHub của bạn, nơi bạn có toàn quyền push. Clone là bản sao tải về máy tính của bạn bằng lệnh git clone. Branch là một nhánh phát triển nằm bên trong một repo, dù repo đó là repo gốc, là fork hay là bản clone trên máy. Ba từ này hay bị trộn vào nhau vì khi đóng góp cho một dự án mã nguồn mở, bạn dùng cả ba trong cùng một buổi: fork trên web, clone về máy, rồi tạo branch để sửa.

Lý do cần fork rất đơn giản. Bạn không có quyền push lên repo của người khác. Nếu bạn thử, GitHub từ chối với dòng Permission to <chu-goc>/<repo>.git denied to <ten-ban>. Fork là cách GitHub giải quyết chuyện đó: bạn sao chép repo về tài khoản mình, sửa trên bản sao, rồi gửi pull request (yêu cầu kéo thay đổi) để người quản lý repo gốc xem và merge.

Từ đây, upstream là repo gốc và origin là fork của bạn. Hai tên này chỉ là quy ước, không phải bắt buộc, nhưng gần như mọi tài liệu và mọi câu trả lời trên Stack Overflow đều dùng đúng như vậy, nên bạn cũng nên dùng. Fork là tính năng của GitHub, không phải của Git: không có lệnh git fork. Nếu ranh giới giữa hai thứ đó còn mờ, hãy đọc Git, GitHub và máy chủ Git tự dựng khác nhau thế nào trước khi tiếp.

Fork trên giao diện web GitHub

  1. Mở trang repo bạn muốn đóng góp. Bấm nút Fork ở góc trên bên phải, cạnh nút Star.
  2. Ở mục Owner, chọn tài khoản cá nhân của bạn, hoặc một organization nếu bạn có quyền tạo repo trong đó.
  3. Ô Repository name giữ sẵn tên gốc. Chỉ đổi khi tài khoản của bạn đã có một repo trùng tên.
  4. Ô đánh dấu Copy the ... branch only đang được tích sẵn. Chữ ở giữa là tên nhánh mặc định của repo gốc, có thể là main, master, develop hay bất kỳ tên nào chủ repo đặt.
  5. Bấm Create fork. Vài giây sau bạn được chuyển tới trang fork, với dòng "forked from" ngay dưới tên repo.

Giữ ô đó ở trạng thái tích. Fork của bạn khi đó chỉ chứa nhánh mặc định, gọn và ít gây nhầm khi bạn gõ tên branch. Bạn không mất gì, vì mọi nhánh khác của repo gốc vẫn lấy được bằng git fetch upstream ở bước sau. Chỉ bỏ tích khi bạn cần fork mang đủ mọi nhánh, ví dụ để lưu giữ một dự án sắp bị chủ nhân xoá.

Tài khoản GitHub miễn phí fork được repo công khai không giới hạn số lượng. Những giới hạn thật sự của tài khoản miễn phí nằm ở chỗ khác, và những gì tài khoản GitHub miễn phí cho bạn liệt kê chúng.

Clone fork về máy và thêm remote upstream

git clone git@github.com:<ten-ban>/<repo>.git
cd <repo>
git remote add upstream https://github.com/<chu-goc>/<repo>.git
git remote -v

git remote -v phải liệt kê hai remote, mỗi remote hai dòng vì Git tách địa chỉ fetch và địa chỉ push: origin trỏ về fork của bạn, upstream trỏ về repo gốc. Nếu chỉ thấy origin, lệnh git remote add chưa chạy, và git fetch upstream sau đó sẽ báo fatal: 'upstream' does not appear to be a git repository.

Hai remote dùng hai kiểu địa chỉ là có ý. origin dùng SSH vì bạn sẽ push lên đó, và push cần xác thực. upstream dùng HTTPS vì bạn chỉ fetch từ đó, mà fetch từ repo công khai không cần đăng nhập. Push qua SSH chỉ chạy khi khoá công khai của bạn đã nằm trong tài khoản GitHub. Nếu chưa, lệnh push dừng ở git@github.com: Permission denied (publickey). Tạo khoá bằng ssh-keygen -t ed25519, dán nội dung file .pub vào Settings, mục SSH and GPG keys, rồi kiểm tra bằng ssh -T git@github.com. Lệnh đó phải in một dòng chào có tên tài khoản của bạn. Bài giờ đầu tiên trên GitHub đi từng bước qua phần này, nên ở đây tôi không lặp lại.

Một việc nữa cần làm ngay: tìm tên nhánh mặc định của repo gốc.

git remote show upstream

Dòng bắt đầu bằng HEAD branch: cho biết tên đó. Từ đây tôi viết <nhanh-mac-dinh> trong mọi lệnh, bạn thay bằng tên thật. Đừng đoán là main. Nhiều repo lâu năm vẫn dùng master, một số dùng develop hay trunk.

Luôn sửa trên một branch riêng

Đây là quy tắc quan trọng nhất khi làm việc với fork: không commit trực tiếp lên nhánh mặc định của fork. Nhánh đó chỉ có một việc, là bản sao y nguyên của nhánh mặc định bên upstream. Khi nó y nguyên, đồng bộ chỉ là một lần fast-forward (Git dời con trỏ nhánh tới commit mới hơn, không tạo commit nào), nên không bao giờ có xung đột. Khi bạn commit thẳng lên đó, mỗi lần đồng bộ sinh một merge commit thừa, và pull request của bạn sẽ kéo theo cả những commit không liên quan gì tới việc bạn sửa.

git switch <nhanh-mac-dinh>
git switch -c fix-typo-readme
# sửa file, ví dụ README.md
git add README.md
git commit -m "Fix typo in README"
git push -u origin fix-typo-readme

git switch -c tạo branch mới từ vị trí hiện tại và chuyển sang nó. Đặt tên branch theo việc bạn làm, không đặt test hay abc, vì tên này sẽ hiện trên pull request. git push -u origin đẩy branch lên fork và ghi nhớ origin là remote theo dõi, nên lần sau chỉ cần gõ git push. Chú ý đích là origin, không phải upstream. Push lên upstream bị GitHub từ chối vì bạn không có quyền ghi ở đó.

Đồng bộ fork với upstream: fetch rồi merge hoặc rebase

Repo gốc không đứng yên. Trong lúc bạn sửa, người khác merge thêm commit vào đó. Fork của bạn không tự cập nhật theo, vì fork chỉ là bản sao tại một thời điểm, và GitHub không bao giờ tự đồng bộ hộ bạn. Trang fork sẽ hiện dòng "This branch is N commits behind". Đồng bộ gồm ba bước: kéo commit mới của upstream về máy, đưa chúng vào nhánh mặc định của bạn, rồi đẩy lên fork.

git fetch upstream
git switch <nhanh-mac-dinh>
git merge --ff-only upstream/<nhanh-mac-dinh>
git push origin <nhanh-mac-dinh>

git fetch upstream tải mọi commit và branch mới của repo gốc về máy, lưu dưới tên upstream/<ten-nhanh>, nhưng chưa đụng vào bất kỳ branch nào của bạn. Vì vậy fetch luôn an toàn. git merge --ff-only chỉ chấp nhận fast-forward: nó dời nhánh của bạn tới đúng vị trí của upstream mà không tạo merge commit. Nếu lệnh in ra fatal: Not possible to fast-forward, aborting., nhánh mặc định của bạn đang có commit mà upstream không có, nghĩa là bạn đã commit thẳng lên nó. Cách gỡ nằm ở phần lỗi thường gặp bên dưới.

Sau khi nhánh mặc định đã đồng bộ, đưa branch đang làm việc lên trên nền mới:

git switch fix-typo-readme
git rebase upstream/<nhanh-mac-dinh>
git push --force-with-lease origin fix-typo-readme

git rebase gỡ các commit của bạn ra, rồi đặt lại lần lượt lên trên commit mới nhất của upstream. Lịch sử thành một đường thẳng, và pull request chỉ chứa đúng commit của bạn. Vì các commit được viết lại nên mã hash của chúng đổi, và lần push tiếp theo bị từ chối với ! [rejected] kèm chữ (non-fast-forward). Đó là lý do cần --force-with-lease. Cờ này chỉ ghi đè khi branch trên remote vẫn đúng như bản bạn biết. Nếu ai đó, ví dụ một maintainer, đã push thêm commit vào branch của bạn, lệnh dừng lại thay vì xoá công của họ. --force trần không có lớp bảo vệ đó, nên đừng dùng.

Nếu dự án không yêu cầu lịch sử thẳng, bạn có thể merge thay cho rebase: đứng trên fix-typo-readme, chạy git merge upstream/<nhanh-mac-dinh>. Cách này tạo một merge commit và không cần force push, nhưng lịch sử pull request sẽ có thêm commit "Merge branch". Đọc file CONTRIBUTING.md của dự án để biết họ muốn cách nào.

Khi rebase hoặc merge gặp xung đột, Git dừng và in CONFLICT (content): Merge conflict in <file>. Mở file đó, tìm các dấu <<<<<<<, ======= và >>>>>>>, giữ lại phần đúng, xoá các dấu, rồi git add <file> và git rebase --continue. Nếu rối quá, git rebase --abort đưa branch về đúng trạng thái trước khi rebase.

Hai đường tắt: nút Sync fork và gh repo fork

Trên trang fork của bạn, phía trên danh sách file, có menu Sync fork. Bấm vào, xem các commit mới của upstream, rồi bấm Update branch. GitHub tự merge nhánh mặc định của upstream vào nhánh mặc định của fork ngay trên máy chủ. Khi fork sạch, không có commit riêng, đây là một fast-forward và không có gì để lo. Khi có xung đột, GitHub không tự merge mà đề nghị bạn mở pull request hoặc bỏ các commit riêng đó. Nút này chỉ cập nhật fork trên GitHub. Bản clone trên máy bạn vẫn cũ, nên sau khi bấm bạn vẫn phải chạy git pull --ff-only trên nhánh mặc định.

Công cụ dòng lệnh chính thức của GitHub là gh. Trên Ubuntu 24.04 nó có sẵn trong kho:

sudo apt update && sudo apt install -y gh
gh auth login
gh repo fork <chu-goc>/<repo> --clone

Bản gh trong kho Ubuntu cũ hơn bản mới nhất vài phiên bản; nếu cần tính năng mới, thêm kho apt của GitHub theo hướng dẫn tại cli.github.com. gh auth login hỏi vài câu và mở trình duyệt để bạn đăng nhập. gh repo fork --clone làm bốn việc trong một lệnh: tạo fork trên GitHub, clone fork về thư mục hiện tại, đặt fork làm remote origin, và đặt repo gốc làm remote upstream. Thêm --default-branch-only để có cùng hiệu ứng với ô đánh dấu trên web, thêm --org <ten-org> để fork vào một organization.

Đồng bộ bằng gh cũng có hai lệnh:

gh repo sync
gh repo sync <ten-ban>/<repo>

Lệnh thứ nhất, không có tham số, đồng bộ bản clone trên máy từ upstream, tương đương với fetch và merge ở trên. Lệnh thứ hai, có tên fork, đồng bộ fork trên GitHub từ repo gốc, tương đương với nút Sync fork. Cờ --branch chọn nhánh khác nhánh mặc định. Cờ --force ép nhánh của fork về đúng trạng thái upstream và xoá mọi commit riêng trên nhánh đó, nên chỉ dùng khi bạn chắc.

Mở pull request về upstream

Sau khi push branch lên fork, mở trang fork trên GitHub. Một khung màu vàng hiện tên branch vừa push cùng nút Compare & pull request. Nếu khung không hiện, vào repo gốc, chọn tab Pull requests, bấm New pull request, rồi bấm liên kết compare across forks.

Kiểm tra bốn ô ở đầu trang so sánh. base repository là repo gốc và base là nhánh mặc định của nó. head repository là fork của bạn và compare là fix-typo-readme. Nếu base repository lại là fork của bạn, pull request sẽ tự gửi cho chính bạn, và maintainer không bao giờ thấy nó. Viết tiêu đề ngắn, mô tả rõ bạn sửa gì và vì sao. Tích ô Allow edits by maintainers để người quản lý dự án có thể push sửa nhỏ thẳng vào branch của bạn thay vì phải yêu cầu bạn làm.

Nếu dùng gh, đứng trong branch và chạy gh pr create --web. Lệnh mở trình duyệt với trang so sánh đã điền sẵn đúng fork và đúng repo gốc.

Pull request theo branch, không theo commit. Mọi commit bạn push thêm lên fix-typo-readme ở origin sẽ tự hiện trong pull request đang mở. Khi maintainer yêu cầu sửa, bạn chỉ cần commit và push, đừng mở pull request mới. Khi pull request đã được merge, dọn dẹp:

git switch <nhanh-mac-dinh>
git fetch upstream
git merge --ff-only upstream/<nhanh-mac-dinh>
git push origin <nhanh-mac-dinh>
git branch -d fix-typo-readme
git push origin --delete fix-typo-readme

GitLab dùng cùng mô hình fork và merge request

Nhiều công ty ở Việt Nam chạy GitLab tự dựng thay cho GitHub, nên có khả năng cao là bạn gặp mô hình này ở nơi làm việc trước cả khi đóng góp mã nguồn mở. Tin tốt là không có gì phải học lại. GitLab cũng có nút Fork trên trang dự án, chọn namespace (tương đương với Owner), và gọi pull request là merge request. Hai tên khác nhau cho cùng một việc.

Mọi lệnh Git ở trên chạy y nguyên trên GitLab. Chỉ đổi địa chỉ remote, ví dụ git remote add upstream https://gitlab.example.com/<nhom>/<repo>.git. Khoá SSH cũng tạo bằng ssh-keygen -t ed25519 như trên GitHub, chỉ khác chỗ dán: trên GitLab là Preferences, mục SSH Keys. Gitea và Forgejo, hai phần mềm máy chủ Git nhẹ hơn mà nhiều đội tự cài, dùng cùng mô hình fork và pull request. Nếu bạn định tự dựng một máy chủ như vậy, các lựa chọn máy chủ Git tự dựng so sánh chúng.

Lỗi thường gặp và cách sửa

fatal: Not possible to fast-forward, aborting. Bạn đã commit thẳng lên nhánh mặc định của fork. Cứu các commit đó vào một branch, rồi ép nhánh mặc định về đúng upstream:

git switch <nhanh-mac-dinh>
git branch cuu-commit
git reset --hard upstream/<nhanh-mac-dinh>
git push --force-with-lease origin <nhanh-mac-dinh>

Branch cuu-commit giữ nguyên công việc của bạn. Chuyển sang nó, chạy git rebase upstream/<nhanh-mac-dinh>, và từ giờ làm việc trên đó.

! [rejected] kèm (non-fast-forward) khi push. Lịch sử branch trên máy khác với trên remote. Sau một lần rebase, đây là điều bình thường và --force-with-lease là câu trả lời. Nếu bạn chưa rebase gì mà vẫn gặp, có người khác đã push vào branch đó; chạy git pull --rebase trước rồi push lại.

git@github.com: Permission denied (publickey). Khoá SSH chưa được thêm vào tài khoản, hoặc máy đang dùng khoá khác với khoá bạn đã dán. Kiểm tra bằng ssh -T git@github.com, và nếu vẫn bị từ chối, ssh -vT git@github.com liệt kê từng file khoá mà SSH thử.

Permission to <chu-goc>/<repo>.git denied to <ten-ban>. Bạn push lên upstream thay vì origin. Chạy git push -u origin <ten-branch>. Lệnh git remote -v cho biết remote nào là gì nếu bạn không nhớ.

Pull request chứa commit của người khác. Branch của bạn được tạo từ một nhánh mặc định đã cũ, hoặc nhánh mặc định của fork có merge commit thừa. Đồng bộ nhánh mặc định theo phần trên, rồi git rebase upstream/<nhanh-mac-dinh> trên branch làm việc và push lại bằng --force-with-lease. Pull request sẽ chỉ còn commit của bạn.

FAQ

Fork và clone khác nhau thế nào?

Fork là bản sao repo nằm trên GitHub, dưới tài khoản của bạn, và bạn có quyền push lên đó. Clone là bản sao nằm trên máy tính của bạn, tạo bằng git clone. Để đóng góp cho repo của người khác, bạn cần cả hai: fork để có nơi push, clone để có nơi sửa code. Fork là tính năng của GitHub, còn clone là lệnh của Git.

Tại sao fork của tôi bị báo "commits behind" dù tôi không sửa gì?

Vì fork là bản sao tại một thời điểm, và GitHub không bao giờ tự đồng bộ nó với repo gốc. Mỗi commit được merge vào upstream sau lúc bạn fork đều làm fork tụt lại một commit. Bấm Sync fork rồi Update branch trên web, hoặc chạy git fetch upstream, git merge --ff-only upstream/<nhanh-mac-dinh> và git push origin <nhanh-mac-dinh> trên máy.

Nên dùng merge hay rebase khi đồng bộ fork?

Trên nhánh mặc định của fork, luôn dùng git merge --ff-only, vì nhánh đó phải y nguyên upstream và không được có merge commit. Trên branch làm việc, rebase cho lịch sử thẳng và pull request sạch, nhưng cần git push --force-with-lease. Merge không cần force push nhưng để lại merge commit trong pull request. Nếu file CONTRIBUTING.md của dự án có yêu cầu, làm theo họ.

Có thể xoá fork sau khi pull request đã được merge không?

Có. Các commit đã merge nằm trong repo gốc, không phụ thuộc vào fork nữa. Đừng xoá khi pull request còn mở, vì pull request lấy commit từ branch trên fork và bạn sẽ không push thêm sửa đổi được. Nếu sau này muốn đóng góp tiếp, chỉ cần fork lại.

GitLab có fork và pull request không?

Có, chỉ khác tên gọi: GitLab gọi pull request là merge request. Nút Fork, remote upstream, git fetch upstream và git rebase đều dùng y như trên GitHub. Chỗ dán khoá SSH trên GitLab nằm ở Preferences, mục SSH Keys.

#github#git#fork#pull-request#open-source#beginner