Trình quản lý phiên bản (Git)

Hệ thống quản lý phiên bản (Version Control Systems - VCS) là các công cụ để theo dõi những thay đổi trong mã nguồn (hay các thư mục và tập tin). Đúng như tên gọi của chúng, các công cụ này giúp ta lưu giữ lịch sử thay đổi và thậm chí là tạo điều kiện cho việc hợp tác với người khác. Các VCS theo dõi sự thay đổi của các tập và thư mục con bằng cách lưu giữ toàn bộ trạng thái của chúng qua các “snapshot” (cơ sở dữ liệu “ảnh chụp”). Những snapshot này được lưu trong một tập trên cùng hay tập gốc của dự án ta muốn quản lý phiên bản. Ngoài ra các VCS cũng chứa đựng các metadata (thông tin phụ) như tác giả của “snapshot”, tin nhắn và giải thích bởi tác giả cho “snapshot” đó, v.v.

Vì sao ta cần các trình quản lý phiên bản? Thập chí ngay khi bạn lập trình cho một dư án cá nhân, VCS có thể cho phép ta xem lại sự thay đổi, mốc thời gian của chúng, lí do cho các thay đổi đó và các tiến trình trên các branch (nhánh) khác nhau của cây lịch sử . Khi làm việc nhóm, đây lại là một công cụ vô cùng hiệu quả để theo dõi thay đổi từ các đồng sự và giải quyết các conflicts (mâu thuẫn) từ thay đổi mã nguồn của ta và họ.

Các VCS hiện đại cũng có thể trả lời các câu hỏi sau một cách dễ dàng và đa phần tự động:

Mặc dù có nhiều trình VCS, nhưng Git là công cụ thông dụng nhất cho việc quản lý phiên bản. Hình ảnh truyện tranh sau từ XKCD comic phần nào cho ta thấy danh tiếng của Git:

xkcd 1597

(dịch: A - Đây là Git, nó theo dõi việc hợp tác trong các dự án có mã nguồn bằng mộc biểu đồ cây xinh xắn. B - Ngon, thế dùng nó thế nào? A - Tôi không biết. Cứ nhớ các câu lệnh shell này và gõ chúng để cập nhật giữa mã của chúng ta. Nếu có lỗi thì lưu giữ thay đổi của bạn ở chỗ khác, rồi xóa nguyên dự án đó đi và tải về một bản lưu giữ mới toanh…)

Vì giao diện của Git là một dạng leaky abstraction (trừu tượng có rò rĩ), tìm hiểu về cách sử dụng Git theo phương pháp top-down (từ cao xuống thấp, bắt đầu từ giao điện câu lệnh của nó) có thể gây ra vô vàn sự mất phương hướng (đặc biệt cho người mới học). Bạn hoàn toàn có thể học thuộc một loạt các câu lệnh, coi chúng như thần chú và làm theo bức hình trên nếu có gì đó sai.

Mặc dù Git có một giao diện thật sự là tệ hại, triết lý thiệt kế và hoạt động của nó vô cùng ấn tượng. Trong khi một giao diện tệ hại này cần phải được học thuộc lòng, một thiết kế ấn tượng có thể được hiểu tận. Vì lí do này, chúng ta sẽ học Git theo cách bottom-up (từ dưới lên trên), bắt đầu từ data model (mô hình dữ liệu) rồi sau đó mới đến các câu lệnh. Khi ta đã hiểu mô hình dữ liệu của nó, ta hoàn toàn có thể giải thích cách các câu lênh Git hoạt động (bằng việc thay đổi mô hình dữ liệu trên).

Mô hình dữ liệu của Git

«««< HEAD Có vô vàn cách để thiết kế một VCS. Tuy nhiên Git có một mô hình dữ liệu được thiết kế kỹ càng để tạo nên các tính năng tuyệt vời của một VCS như lưu giữ lịch sử, hỗ trợ các branch và cho phép hợp tác giữa người dùng. ======= There are many ad-hoc approaches you could take to version control. Git has a well-thought-out model that enables all the nice features of version control, like maintaining history, supporting branches, and enabling collaboration.

7623daf79f8111f5d72aeeea85808bc2a51772f0

Snapshots (“Ảnh chụp”)

Git mô phỏng lịch sử của các tập tin và thư mục nó theo dõi dưới dạng chuỗi các snapshot trong một thư mục top-level (thư mục gốc của dự án). Theo ngôn ngữ của Git, một tập tin được gọi là “blob”, và nó chỉ là một đống bytes dự liệu. Thư mục thì lại gọi là “tree” (cây), và nó lưu giữ tên đến các tree hay blob khác (thư mục có thể chứa thư mục con). Một snapshot là cây gốc trên cùng mà ta đang theo dõi. Ví dụ như ta có một cây thư mục như sau:

<root> (tree)
|
+- foo (tree)
|  |
|  + bar.txt (blob, nội dung = "hello world")
|
+- baz.txt (blob, nội dung = "git is wonderful")

Cây thư mục gốc gồm hai thành phần, một tree (cây con) tên “foo” (và nó chứa một thành phần là blob “bar.txt”), và blob “baz.txt”.

Mô phỏng lịch sử: cách kết nối các snapshot

Các VCS nên kết nối các snapshot như thế nào để có nghĩa? Một mô hình đơn giản đó là linear history (lịch sử tuyến tính). Mô hình lịch sử này cấu thành từ các snapshot theo thứ tự thời gian mà chúng được tạo. Tuy nhiên, vì vô vàn lí do, Git không dùng một mô hình đơn giản như vậy.

«««< HEAD Trong Git, lịch sử được mô phỏng bằng một Directed Acyclic Graph (Đồ thị định hướng không tuần hoàn - DAG). Đấy là một từ phức tạp và đầy toán học, nhưng đừng sợ. Điều này có nghĩa là mỗi snapshot trong Git thì được kết nối, chỉ hướng về một set (tập) các “bố mẹ”, những snapshot đi trước nó trong chuỗi thời gian. Gọi là một tập các bố mẹ thay cho một bố hoặc mẹ (như mô hình linear history nói trên) vì một snapshot có thể có nhiều tổ tiên khác nhau, như trong việc merging (hợp nhất) nhiều branch phát triển song song chẳng hạn. ======= In Git, a history is a directed acyclic graph (DAG) of snapshots. That may sound like a fancy math word, but don’t be intimidated. All this means is that each snapshot in Git refers to a set of “parents”, the snapshots that preceded it. It’s a set of parents rather than a single parent (as would be the case in a linear history) because a snapshot might descend from multiple parents, for example, due to combining (merging) two parallel branches of development.

7623daf79f8111f5d72aeeea85808bc2a51772f0

Các snapshot này được gọi là commit (cam kết). Việc hình dung một history có thể cho ta một thứ như sau:

o <-- o <-- o <-- o
            ^
             \
              --- o <-- o

Trong bức hình ASCII ở trên, kí tự o tượng trưng cho commit (hay snapshot). Các mũi tên chỉ đến bố mẹ của mỗi commit (đó là mối quan hệ “có trước nó”, không phải là “có sau nó”). Đến cái commit thứ 3 thì biểu đồ lịch sử được chia làm hai nhánh riêng. Điều này có thể tương ứng với hai chức năng đang được phát triển song song, độc lập với nhau. Trong tương lai, các branch này có thể được hợp nhất tạo nên một snapshot có cả hai chức năng này. Điều này tạo ra một đồ thị lịch sử như sau:


o <-- o <-- o <-- o <---- o
            ^            /
             \          v
              --- o <-- o

Commit trong Git là bất biến, nghĩa là các lỗi trong commit không thể nào sữa được. Rất may những lỗi này chỉ có nghĩa là các thay đổi đến dòng lịch sử (nội dung của blob hay cấu trúc của tree được theo dõi chẳng hạn) sẽ tạo ra các commit hoàn toàn mới và các references (xem ở phần dưới đây) được cập nhật để chỉ đến các commit vừa tạo (để sửa lỗi sai trong thư mục hay tập tin chẳng hạn).

Mô hình dữ liệu viết theo pseudocode (mã giả)

Mã giả cho mô hình dữ liệu của git có thể có hình thái như sau:

// Một blob hay tập tin là một đống byte
type blob = array<byte>

// Một tree hay thư mục chứa các tập tin và thư mục con
type tree = map<string, tree | blob>

// Một commit có bố mẹ, các thông tin phụ và cây thư mục gốc nó theo dõi 
type commit = struct {
    parents: array<commit>
    author: string
    message: string
    snapshot: tree
}

Đây là một mô hình đơn giản và sạch cho lưu giữ lịch sử thay đổi.

Vật thể và content-addressing (truy cập địa chỉ từ nội dung)

Một “vật thể” là một blob, tree hay là commit:

type object = blob | tree | commit

Trong kho lưu trữ dữ liệu của Git, các vật thể đều có thể được xác định và truy cập từ nội dụng của chúng (đúng hơn là kết quả của hàm băm SHA-1 hash trên nội dung của chúng )

objects = map<string, object>

def store(object):
    id = sha1(object)
    objects[id] = object

def load(id):
    return objects[id]

Các blob, tree và commit giống nhau theo hướng này: chúng là các vật thể. Khi chúng chỉ đến hay tham khảo các vật thể khác, chúng không trực tiếp lưu trữ hay chứa đựng chúng trên đĩa cứng, mà chỉ đến bằng kết quả của hàm băm trên nội dung của các vật thể này.

Ví dụ như, tree gốc của thư mục trong phần trên (được hình dung bằng câu lệnh git cat-file -p 698281bc680d1995c5f4caaf3359721a5a58d48d) sẽ có dạng nội dung như sau:

100644 blob 4448adbf7ecd394f42ae135bbeed9676e894af85    baz.txt
040000 tree c68d233a33c5c06e0340e4c224f0afca87c8ce87    foo

Tree gốc này có nội dung là các con trỏ đến nội dung của nó (như trên), rồi baz.txt (một blob) và foo (một tree). Nếu ta dùng kết quả hàm băm tương ứng với con trỏ tới baz.txt bằng câu lệnh git cat-file -p 4448adbf7ecd394f42ae135bbeed9676e894af85, nội dung có được (văn bản trong baz.txt) là như sau:

git is wonderful

References - Các con trỏ tham khảo

Các snapshot đều có thể được xác định bằng kết quả hàm băm SHA-1 lên nội dung của chúng. Thật là bất tiện vì loài người không hề giỏi ghi nhớ các chuỗi 40 kí tự thập lục phân.

Cách giải quyết của Git cho vấn nạn này các tên dễ đọc cho các kết quả của hàm băm trên, gọi là “reference”. Reference là con trỏ đến commit. Khác với các objects (vật thể), bị bất biến, các reference là các biến số (được thay đổi để chỉ đến một commit khác trong chuỗi lịch sử). Ví dụ như master là một reference thường chỉ đến commit mới nhất của branch chính của dự án ta đang phát triển.

references = map<string, string>

def update_reference(name, id):
    references[name] = id

def read_reference(name):
    return references[name]

def load_reference(name_or_id):
    if name_or_id in references:
        return load(references[name_or_id])
    else:
        return load(name_or_id)

Với cơ sở dữ liệu này, Git có thể dùng những cái tên dễ nhớ hơn như “master” để chỉ đến các snapshot (hay commit) nhất định trong lịch sử, thay vì chuỗi thập nhị phân nói trên.

Mội chi tiết thú vị là chúng ta thường cần một khái niệm cho việc “ta đang ở đâu trong hiện tại” của chuỗi lịch sử. Việc này rất cần thiết để ta biết khi ta tạo một snapshot hay commit mới, vị trí tương đối của chúng dựa trên bố mẹ nào. Trong Git, khái niệm “ta đang ở đâu hiện tại” là một reference đặc biệt gọi là “HEAD”.

Repository

Cuối cùng ta có thể định nghĩa (tương đối) repository (kho chứa): đó là các dữ liệu của các objectsreferences.

Trên đĩa cứng, tất cả những gì Git lưu trữ là các object và reference: đó là những thứ quan trọng trong mô hình dữ liệu của Git. Tất cả câu lệnh bắt đầu bằng git tương ứng với việc thay đổi đồ thị DAG bằng các thêm object hay thêm và cập nhật các reference

Mỗi khi nhập một câu lệnh, hãy thử nghĩ về những thay đổi mà câu lệnh đang làm lên mô hình dữ liệu bên dưới. Ngược lại, nếu bạn đang nghĩ đến việc thay đổi đồ thị DAG, ví dụ như “bỏ đi những thay đổi chưa được commit (cam kết) và đưa con trỏ “master” đến commit 5d83f9e, thì chắc chắn rằng có một câu lệnh tương ứng với hành đông đó (trong trường hợp này là git checkout master; git reset --hard 5d83f9e)

Staging area - khu vực trung gian

Đây lại là một khái niệm riêng biệt với mô hình dữ liệu nêu trên, nhưng nó lại là một phần của giao diện để ta tạo các commit.

Hình dung thế này, để tạo nên một phương thức để “chụp lại” các snapshot như ở phần trên, ta có thể tạo một câu lệnh tên là “create snapshot”. Nó sẽ tạo nên snapshot từ trạng thái hiện tại của working directory (thư mục làm việc của ta) mà các VCS đang theo dõi. Nhiều VCS thì chỉ đơn giản như vậy, nhưng với Git thì không. Ta cần các snapshot “sạch” và không phải lúc nào bưng nguyên trạng thái hiện tại của working directory vào lịch sử cũng cần thiết cả.

Giả dụ như bạn đang viết hai chức năng riêng biệt và cần tạo hai commit riêng biệt, cái thứ nhất giới thiệu chức năng một và cái thứ hai thì giới thiệu chức năng thứ hai. Hay trường hợp khác khi bạn có vô vàn câu print (in) để phát hiện lỗi và logic để sửa lỗi; bạn chắc chắn chỉ muốn commit phần sửa lỗi và bỏ qua phần print.

Git có thể cho phép ta thực hiện các trường hợp trên bằng cách cho phép ta nêu ra phần thay đổi nào nên được đưa vào snapshot tiếp theo qua một khu vực trung gian (cách biệt giữa tất cả các thay đổi (unstaged) và các thay đổi đã được lưu trữ vào lịch sử (commit))

Giao diện dòng lệnh của Git

Để tránh việc lặp thông tin, chúng tôi sẽ không giải thích các câu lệnh ở dưới một cách chi tiết. Hãy đọc Pro Git hay xem video bài giảng nếu cần thiết.

Cơ bản

Chia nhánh (branching) và hợp nhánh (merging)

Remotes - Dịch vụ luư trữ mã nguồn từ xa

Undo - Quay lại, Hủy hành động

Nâng cao

Khác

Các tài liệu khác

Bài tập

  1. Nếu bạn không có kinh nghiệm dùng Git, hãy thử đọc vài chương đầu của
    Pro Git hoặc học theo một bài hướng dẫn như Learn Git Branching. Khi bạn làm chúng, hãy thử liên hệ các câu lệnh Git với data model của nó.
  2. Clone repository cho trang web khóa học này.
    1. Tìm hiểu về version history của nó bằng cách hiển thị theo dạng biểu đồ.
    2. Ai là người cuối cùng thay đổi file README.md? (Gợi ý: dùng git log với một đối số)
    3. Lời nhắn liên quan đến lần thay đổi cuối cùng trong dòng collections: của file _config.yml là gì? (Gợi ý: dùng git blamegit show)
  3. Một trong những sai lầm khi sử dụng Git là khi commit một số lượng lớn file không nên được quản lý bởi Git, hay các thông tin nhạy cảm (passwords, mã Auth API, etc). Thử add một file vào repo, rồi tạo một vài commit rồi sau đó xóa chúng đi trong lịch sử. (Bạn có thể tham khảo thêm ở đây).

  4. Thử clone một vài repo từ trang Github, rồi thay đổi một ít files trong đó. Điều gì sẽ xảy ra khi ta dùng git stash? Bạn quan sát được gì khi chạy câu lệnh git log --all --oneline? Chạy git stash pop để xóa đi những tác vụ bạn vừa làm khi chạy git stash. Khi nào thì câu lệnh git stash sẽ có ít cho ta?

  5. Như các trình câu lệnh khác, Git có một file tùy chỉnh ( hay dotfile ) tên là ~/.gitconfig. Hãy tạo một alias (biệt hiệu) git graph trong file trên để thực hiện câu lệnh git log --all --graph --decorate --oneline một cách ngắn gọn.

  6. Bạn có thể tùy chỉnh các file hoặc thư mục mà git bỏ qua (ignore) trong dotfile ~/.gitignore_global sau khi chạy git config --global core.excludesfile ~/.gitignore_global. Hãy làm vậy và tạo một file ignore trên toàn hệ thống để bỏ qua việc theo dõi các file phụ liên quan đến hệ điều hành hay các file tạm của trình biên tập mã nguồn, như .DS_Store.

  7. Fork repo của khóa học này từ website, rồi tìm lỗi chính tả hay một điểm gì đó bạn có thể làm tốt hơn, rồi tạo một pull request trên Github.

Edit this page.

Licensed under CC BY-NC-SA.