Git branch và pull request cho bản sửa lỗi đầu tiên

Git branch và pull request qua một bản sửa thật: tạo nhánh, dọn file để quên, chia commit dễ đọc, đẩy lên và viết mô tả pull request để người khác review được.

Git branch và pull request dùng để làm gì? Branch cho bạn một chỗ riêng để sửa code mà không đụng tới bản đang chạy. Pull request là lời đề nghị gộp chỗ sửa đó vào code chung, sau khi người khác đã đọc. Bạn từng commit thẳng lên main rồi lo lắng chưa? Cùng mình làm lại nhé!

Bản sửa lỗi 70 đồng đang nằm ở đâu?

Ở bài trước, mình viết 11 test bằng Vitest để khóa lỗi đơn 1044 của quán Mộc, đơn có hai ly cà phê muối mà tổng tiền chỉ 70 đồng. (Quán Mộc và các nhân vật trong series là minh họa, còn code và các lệnh trong bài đều chạy thật.) Hàm parsePrice đã sửa lỗi, test đã xanh.

Nhưng tất cả vẫn nằm trong thư mục làm việc trên máy mình. Máy chủ của quán vẫn chạy code cũ, và đơn tiếp theo từ máy bán hàng cũ vẫn sẽ có tổng tiền sai.

Chủ quán cũng vừa báo một tin. Quán nhờ được anh Khoa, một lập trình viên lâu năm, mỗi tuần dành một buổi xem code giúp. Từ giờ, mọi thay đổi cần anh Khoa xem qua trước khi lên máy chủ. Vậy việc của mình là đưa bản sửa tới chỗ anh Khoa đọc được, theo cách dễ đọc nhất.

Đây là trạng thái thư mục của mình lúc bắt đầu. git status --short in mỗi file một dòng, chữ M là file đã có trong Git và vừa bị sửa, còn ?? là file Git chưa theo dõi:

git status --short
 M src/orders.ts
 M src/server.ts
?? .vscode/
?? package-lock.json
?? package.json
?? src/orders.test.ts
?? test-sai/

Hai file sửa là bản vá: orders.ts có thêm parsePrice và normalizeOrder, còn server.ts gọi normalizeOrder khi đọc dữ liệu. package.json, package-lock.json và src/orders.test.ts là Vitest và các test. Hai mục còn lại là đồ để quên. .vscode/ chứa cấu hình debug mình tạo ở bài về breakpoint, còn test-sai/ là ví dụ test viết sai cách mà mình giữ lại để so sánh.

Nếu bạn làm theo các bài trước, thư mục của bạn cũng đang như vậy. Nếu không, các lệnh trong bài vẫn dùng được cho bất kỳ kho Git nào có thay đổi chưa commit.

Commit thẳng lên main thì có sao?

Cách nhanh nhất là ba lệnh mà mình đoán ai cũng từng gõ. git add . đưa mọi thứ trong thư mục vào commit, git commit ghi lại, rồi git push đẩy lên. Mình thử hai lệnh đầu để xem commit đó trông thế nào:

git add . && git commit -m 'fix bug'
[main eef187b] fix bug
 7 files changed, 1237 insertions(+), 2 deletions(-)
 create mode 100644 .vscode/launch.json
 create mode 100644 package-lock.json
 create mode 100644 package.json
 create mode 100644 src/orders.test.ts
 create mode 100644 test-sai/orders-chep-logic.test.ts

Bạn nhìn dòng thứ hai, rồi thử đặt mình vào vị trí anh Khoa. Một commit tên "fix bug", 7 file, 1.237 dòng thêm vào. Lệnh git show --stat cho thấy rõ hơn các dòng đó nằm ở đâu:

git show --stat --oneline HEAD
eef187b fix bug
 .vscode/launch.json                |   12 +
 package-lock.json                  | 1118 ++++++++++++++++++++++++++++++++++++
 package.json                       |   13 +
 src/orders.test.ts                 |   54 ++
 src/orders.ts                      |   21 +
 src/server.ts                      |    7 +-
 test-sai/orders-chep-logic.test.ts |   14 +
 7 files changed, 1237 insertions(+), 2 deletions(-)

Bản sửa thật sự chỉ là 28 dòng trong orders.ts và server.ts. Nó bị vùi giữa 1.118 dòng package-lock.json do npm tự sinh, cấu hình VS Code của riêng mình, và một file test mà chính mình đã ghi chú là viết sai. Trong server.ts còn một dòng console.log mình thêm lúc debug rồi quên xóa, và git add . đã mang nó theo.

Cách làm này có ba vấn đề. Tên "fix bug" không nói sửa lỗi gì, nên ba tháng sau đọc lịch sử, không ai biết commit này liên quan tới đơn 1044. Commit trộn bản sửa với test và đồ linh tinh, nên nếu bản sửa gây lỗi và cần gỡ ra, bạn phải gỡ luôn cả test. Và quan trọng nhất, commit nằm trên main. Đẩy lên là nó thành code chung ngay, trước khi anh Khoa kịp đọc dòng nào.

May là mình chưa push. Một commit chưa đẩy lên vẫn sửa được thoải mái. git reset HEAD~1 gỡ commit cuối ra nhưng giữ nguyên mọi thay đổi trong thư mục:

git reset HEAD~1
Unstaged changes after reset:
M	src/orders.ts
M	src/server.ts

Thư mục quay về đúng trạng thái ban đầu, và main lại sạch. Nếu lỡ commit nhầm nhánh hay muốn sửa commit cuối, bài năm lệnh Git giúp bạn thoát khỏi rắc rối thường gặp có sẵn cách gỡ cho từng trường hợp.

Tạo nhánh cho bản sửa thế nào?

Một nhánh (branch) trong Git chỉ là một cái tên trỏ tới một commit. Tạo nhánh mới không sao chép file nào, nên nó gần như không tốn gì. Mọi commit bạn tạo sau đó nằm trên nhánh mới, còn main đứng yên cho tới khi có người gộp nhánh vào.

Lệnh `git switch -c` tạo nhánh mới và chuyển sang nó luôn:

git switch -c fix/gia-don-hang-may-cu
Switched to a new branch 'fix/gia-don-hang-may-cu'

Các thay đổi chưa commit đi theo mình sang nhánh mới, nên không mất gì. Mình không cần commit hay cất chúng đi trước.

Tên nhánh có hai phần. Tiền tố fix/ nói đây là sửa lỗi, và với tính năng mới thì mình dùng feat/. Phần sau mô tả ngắn việc đang làm, viết thường, nối bằng dấu gạch ngang. Git không bắt buộc quy ước nào, nhưng tên có nghĩa giúp người khác nhìn danh sách nhánh là biết nhánh nào làm gì. Một nhánh tên test2 hay nhanh-cua-minh thì không ai dám xóa, vì không ai biết nó chứa gì.

Tech Notes dùng đúng quy ước này. File hướng dẫn của repo ghi rõ là không commit thẳng lên main, mỗi thay đổi bắt đầu từ một nhánh feat/ hoặc fix/, và mỗi pull request chỉ chứa một tính năng hoặc một bản sửa.

Trước khi commit, nên xem lại những gì?

Mình có một thói quen là đọc lại diff của chính mình trước khi commit, như thể đang review code của người khác. git diff kèm tên file cho thấy file đó đổi gì so với lần commit gần nhất. Dòng bắt đầu bằng - là dòng bị xóa, còn dòng bắt đầu bằng + là dòng được thêm:

git diff src/server.ts
diff --git a/src/server.ts b/src/server.ts
index 7eb56c5..97eb4df 100644
--- a/src/server.ts
+++ b/src/server.ts
@@ -1,10 +1,13 @@
 import { readFileSync } from "node:fs";
 import { createServer } from "node:http";
 
-import { listOrders, type Order } from "./orders.ts";
+import { listOrders, normalizeOrder, type RawOrder } from "./orders.ts";
 
 const dataFile = new URL("../data/orders.json", import.meta.url);
-const orders: Order[] = JSON.parse(readFileSync(dataFile, "utf8"));
+// Chuẩn hóa ngay khi đọc file: từ đây trở đi, giá luôn là số.
+const rawOrders: RawOrder[] = JSON.parse(readFileSync(dataFile, "utf8"));
+const orders = rawOrders.map(normalizeOrder);
+console.log("DEBUG don 1044:", orders[3]);
 
 export const server = createServer((req, res) => {
   if (req.url === "/don-hang") {

Bạn nhìn dòng + cuối cùng. Đó là dòng console.log để quên. Nếu nó lên máy chủ, mỗi lần khởi động sẽ in ra tên và số điện thoại của chị Lê Chi trong log. Mình xóa dòng đó trong editor, rồi xem lại tổng quan:

git diff --stat
 src/orders.ts | 21 +++++++++++++++++++++
 src/server.ts |  6 ++++--
 2 files changed, 25 insertions(+), 2 deletions(-)

Hai phút đọc diff giúp mình tự bắt được lỗi này. Nếu để anh Khoa bắt, mình mất một vòng bình luận, sửa, đẩy lại, và anh ấy mất thời gian cho một thứ lẽ ra không nên tới tay anh ấy.

Chia commit thế nào cho dễ đọc?

Mình chia thay đổi thành hai commit, mỗi commit làm một việc mà người đọc hiểu được một mình.

Commit đầu là bản sửa. Thay vì git add ., mình gọi tên đúng hai file:

git add src/orders.ts src/server.ts && git status --short
M  src/orders.ts
M  src/server.ts
?? .vscode/
?? package-lock.json
?? package.json
?? src/orders.test.ts
?? test-sai/

Chữ M giờ nằm ở cột đầu thay vì cột thứ hai. Cột đầu là vùng chờ commit (staging area), tức những gì sẽ vào commit tiếp theo. Các dòng ?? vẫn đứng ngoài.

Rồi mình chạy git commit không kèm -m, để Git mở editor và mình viết được thông điệp nhiều dòng:

Thông điệp của commit thứ nhất
fix: đọc đúng giá dạng "35.000" của máy bán hàng cũ

Máy bán hàng cũ ghi giá là chuỗi có dấu chấm ngăn hàng nghìn ("35.000").
JavaScript đọc dấu chấm thành dấu thập phân, nên 2 ly cà phê muối của
đơn 1044 có tổng 70 đồng thay vì 70.000.

parsePrice đổi giá về số ngay khi đọc dữ liệu (normalizeOrder), và báo
lỗi với định dạng lạ như "35,000" thay vì đoán.

Dòng đầu nói commit làm gì, đủ ngắn để hiện trọn trong git log --oneline. Phần thân nói vì sao. Diff đã cho thấy code đổi thế nào, nhưng không diff nào nói được rằng máy cũ ghi giá kiểu "35.000", hay vì sao "35,000" bị từ chối. Bài viết commit message dễ đọc có thêm mẫu cho các tình huống khác.

Commit thứ hai là test. Trước khi commit, mình chạy lại test một lần, vì vừa xóa một dòng trong server.ts:

npm test
 Test Files  1 passed (1)
      Tests  11 passed (11)
git add package.json package-lock.json src/orders.test.ts && git status --short
A  package-lock.json
A  package.json
A  src/orders.test.ts
?? .vscode/
?? test-sai/
Thông điệp của commit thứ hai
test: thêm Vitest và test khóa lỗi đơn 1044

11 test: parsePrice (5 định dạng đọc được, 4 định dạng bị từ chối),
đơn 1044, và tổng tiền của cả bốn đơn trong data/orders.json.
Chạy: npm test

Vì sao tách làm hai mà không gộp một? Anh Khoa có thể đọc commit đầu, 27 dòng, và hiểu trọn bản sửa mà chưa cần nhìn tới test. package-lock.json với hơn một nghìn dòng nằm gọn trong commit thứ hai, nơi ai cũng biết nó là file npm tự sinh và không cần đọc. Nếu sau này cần đưa riêng bản sửa sang một nhánh khác, mình lấy được đúng một commit.

Theo mình, không cần chia nhỏ hơn nữa. Mỗi hàm một commit thì lịch sử thành vụn, và người đọc phải ghép lại trong đầu. Câu hỏi mình hay tự hỏi là commit này có tên được bằng một câu không có chữ "và" hay không.

Còn hai mục ??. Thư mục test-sai/ đã xong việc của nó nên mình xóa. Thư mục .vscode/ thì mình muốn giữ trên máy nhưng không muốn đưa vào kho, vì đó là cấu hình của riêng mình. File .gitignore là cho cả nhóm, còn cho riêng một máy thì Git có file .git/info/exclude. Mình thêm một dòng .vscode/ vào đó, và git status --short không còn in ra gì.

Giờ xem lại những gì nhánh này có mà main chưa có. Cú pháp main..HEAD nghĩa là các commit có ở vị trí hiện tại nhưng không có trên main:

git log --oneline main..HEAD
a84a836 test: thêm Vitest và test khóa lỗi đơn 1044
86bc923 fix: đọc đúng giá dạng "35.000" của máy bán hàng cũ

Hai dòng này đã kể được câu chuyện của cả nhánh. So với một dòng "fix bug" lúc nãy, anh Khoa biết mình sắp đọc gì trước khi mở file đầu tiên.

Đẩy nhánh lên và mở pull request

Nhánh vẫn chỉ có trên máy mình. git push đẩy nó lên kho chung, và -u origin bảo Git nhớ rằng nhánh này đi cặp với nhánh cùng tên trên kho origin, để các lần sau chỉ cần gõ git push:

git push -u origin fix/gia-don-hang-may-cu
Branch 'fix/gia-don-hang-may-cu' set up to track remote branch 'fix/gia-don-hang-may-cu' from 'origin'.
To ../don-hang-moc.git
 * [new branch]      fix/gia-don-hang-may-cu -> fix/gia-don-hang-may-cu

Mình chạy lệnh này với một kho Git đặt ngay trên máy làm kho chung, để bài không phụ thuộc vào tài khoản nào, và mình rút gọn đường dẫn ở dòng To. Khi kho chung nằm trên GitHub, output có thêm vài dòng bắt đầu bằng remote:, trong đó có một đường link để mở pull request cho nhánh vừa đẩy.

Pull request, hay viết tắt là PR, là một trang trên GitHub nói rằng "tôi muốn gộp nhánh này vào main, mời mọi người xem". Theo tài liệu của GitHub về tạo pull request, bạn chọn nhánh nguồn và nhánh đích, viết tiêu đề và mô tả, rồi bấm tạo. Trang đó hiện toàn bộ diff, danh sách commit, và cho người khác bình luận vào từng dòng code. GitLab gọi cùng thứ này là merge request.

Có một điều mình muốn nói rõ. Pull request không phải tính năng của Git mà là của nền tảng lưu trữ code. Git chỉ biết nhánh và commit, còn phần thảo luận và nút Merge là của GitHub.

Mô tả pull request viết gì?

Mô tả PR là thứ đầu tiên người review đọc, trước cả code. Mình viết theo bốn mục, và đây là mô tả cho PR này:

Mô tả pull request
fix: đọc đúng giá dạng "35.000" của máy bán hàng cũ

## Vấn đề
Đơn 1044 (2 ly cà phê muối) hiện tổng 70 đồng thay vì 70.000 đồng.
Máy bán hàng cũ ghi giá là chuỗi "35.000". JavaScript đọc dấu chấm thành dấu thập phân, nên "35.000" * 2 = 70.

## Thay đổi
- `parsePrice` đổi giá về số ngay khi đọc dữ liệu, qua `normalizeOrder` trong `server.ts`.
- Định dạng lạ ("35,000", "35k", chuỗi rỗng) báo lỗi thay vì đoán.
- Thêm Vitest và 11 test, trong đó có test riêng cho đơn 1044.

## Cách kiểm tra
1. `npm install && npm test`: 11 test xanh.
2. `node src/server.ts`, mở http://localhost:3000/don-hang: đơn 1044 có `"tong": 70000`.

## Chưa làm trong PR này
- Chưa kiểm tra dữ liệu đơn hàng lúc nhận từ máy cũ (số lượng âm, thiếu tên món).
- Nếu `orders.json` có một giá sai định dạng, máy chủ dừng ngay lúc khởi động. Mình muốn hỏi ý kiến: nên dừng, hay bỏ qua đơn đó và ghi log?

Mục "Vấn đề" dành cho người chưa biết gì về đơn 1044. Anh Khoa không có mặt lúc mình debug, nên anh ấy cần biết triệu chứng và nguyên nhân trước khi đọc cách sửa.

Mục "Cách kiểm tra" là mục mình thấy hay bị bỏ nhất, mà lại tiết kiệm thời gian nhất. Người review không phải đoán cách chạy thử. Họ chép hai lệnh, và nếu kết quả khác với mô tả thì đó đã là một phát hiện.

Mục "Chưa làm" nghe như tự khai điểm yếu, nhưng nó có ích theo hai cách. Nó cho người review biết mình đã nghĩ tới các trường hợp đó và cố ý để sau, nên họ khỏi viết bình luận hỏi lại. Dòng cuối còn là một câu hỏi thật. Mình chưa chắc máy chủ nên dừng hẳn hay bỏ qua đơn lỗi, và hỏi ngay trong mô tả nhanh hơn là chờ bị hỏi.

Tech Notes cũng đi theo nhịp này. Hôm 10/11/2026, sitemap của blog không tự cập nhật bài mới. Bản sửa đầu tiên đi vào bằng pull request số 36 từ nhánh fix/sitemap-refresh. Sau khi deploy mới biết nó chưa trúng nguyên nhân gốc, và bản sửa thứ hai là pull request số 37 từ nhánh fix/sitemap-static-path. Mỗi PR chỉ làm một việc, nên khi PR đầu chưa đủ, lịch sử vẫn cho thấy rõ đã thử gì và vì sao phải làm tiếp.

Mười hai bình luận sau một buổi tối

Mình mở pull request lúc chiều. Sáng hôm sau, trang PR có 12 bình luận của anh Khoa. Vài cái chỉ là câu hỏi, như "vì sao parsePrice ném lỗi mà không trả về null?". Vài cái đề nghị đổi tên. Có một cái bắt đầu bằng "Chỗ này mình nghĩ sẽ có vấn đề khi…".

Phản ứng đầu tiên của mình là hơi chột dạ. Code chạy đúng, test xanh, mà vẫn có tới mười hai chỗ để nói. Nhưng đó chính là lý do bản sửa đi qua pull request thay vì lên thẳng main: mười hai chỗ này được nói ra trước khi code tới máy chủ của quán.

Đọc bình luận nào trước, trả lời thế nào, cái nào nên sửa và cái nào nên trao đổi lại là chủ đề của bài viết tiếp theo. Bạn theo dõi cả câu chuyện ở series Nhận dự án cũ. Nếu đang ôn phỏng vấn, câu hỏi về merge và rebase có trong ngân hàng câu hỏi phỏng vấn.

Nhận bài tiếp theo qua email.

Một email mỗi tuần, chỉ khi có điều đáng đọc. Không spam.