易學難精:Git 簡單的部分(二)

前言

上一篇 易學難精:Git 簡單的部分(一) 我們說明了 git 基本指令的運作原理,HEAD、commit、tree、blob 也會有如下的階層樹的關係:
img
下層的內容異動間接影響上層的雜湊值,這也是為什麼每次 Commit 都會產生新的 雜湊值(Commit Id) 的原因。
img

我們可以把 Repository 想像成一個圖書館,而 Commit 可以想像成書。
img
每一次 Commit 都會產生一本書,每一本書都會被賦予唯一的 ISBN(國際標準書號),也就是 Commit Id,Tree 就好像段落章節,Blob 則是章節的內容,HEAD (Branch) 則是書名。
img
所以 Commit 除了產生一本書之外,還會把目前的書名剪下來貼到新書封面上。
img
而 Commit 會紀錄由哪一本書(Commit)延續下來的,最後就像長篇小說一樣變成系列作品(Commit History),只是差別在於小說是不斷延伸新的內容,而 git 則是在同一個主題內不斷調整內容。
img

匿名分支 (detached HEAD)

我們說每個 commit 都是一本”完整”的書,所以我們除了可以以書名(HEAD)來取得書籍外還可以拿書號(Commit Id)來取得書籍,拿上篇的範例來看 h1976bfb2 其實都是同一本書,所以不論以 HEAD 或是 Commit Id 都可以取得相同的內容,有趣的是當我們利用 Commit Id 來取書時會發現出現 detached HEAD 的警告,git 會建議你透過 git checkout -b <new-branch-name> 在讀取時同時建立一個新分支。
img
開啟 \.git\HEAD 可以看到確實記錄現在 commit id 976bfb2,但是不會有 HEAD(\.git\refs\heads\) 對應到這個 commit id。
img
再回想一下 Commit 在做什麼?除了產生一本新書外,還會將目前的書名(HEAD)貼到新書上,但是我們並不知道目前的書名是什麼,也許你會想為什麼 git 不自動去比對 \.git\refs\heads\ 內所有的 HEAD,但是別忘了在 git 內不同的書名(HEAD)可以對應的同一本書(Commit Id),所以它會不知道要使用哪一個書名。
img
另一點,我們的線圖雖然是將 Commit 的歷史紀錄給串接起來,不過都是以 HEAD 為出發點,匿名分支 (detached HEAD) 代表它沒有對應的 HEAD,所以正常情況下不會出現在線圖上,就像一本無法直接借閱的書籍,除非我們記得這本書的書號(Commit Id)。
但是在 detached HEAD 狀態下我們仍然可以 commit,只是這個”分支”實際上並沒有 HEAD。
img
我們可以說當 \.git\HEAD 是參考自 \.git\refs\heads\ 內的 HEAD 時就是一般分支,**當 \.git\HEAD 直接對應到 commit id 時就是匿名分支 (detached HEAD)**。
img
再次確認一下,目前分支 master 所對應的 commit id 為 aece387
img
我們可以發現只要是透過 commit id 來切換分支時都會變成 detached HEAD,既使它有對應的 HEAD master
img

模擬

情境:客戶突然取消新功能,希望我們維護既有功能即可。
透過 TortoiseGit 的日誌或是命令 git log 可以查詢當前 commit 紀錄。
img

注意:976bfb2 並不是 master 的 commit,為了避免搞混可以取消勾選”所有分支”,只顯示當前分支。
img

依照時間順序可以繪製出如下的線圖。
img
我們回復到開發新功能之前的版本,這邊假設是上一個版本 910d17e,我們可以透過日誌來重新(reset)當前的 HEAD master 所對應的 commit id。
img

亦可透過指令 git reset --hard <commit id> 來完成。
img
不過透過日誌來變更有一個好處,如果選錯 commit id 還可以立即恢復回原本的 commid id。
img
或者可以查詢 \.git\logs\refs\heads\ 內所對應的 HEAD,來了解 commid id 變更紀錄。
img

從日誌來看 aece387 這一本書已經消失了。
img
img
接著我們做一些系統修正並提交,這邊假設產生一個 hotfix.txt 檔案。
img
由日誌可以看到 git 重新產生不同於以往的新分支 ad23163
img

問題

如果我們的動作仿照原本 commit aece387 所做的事情,增加一個 master.txt 檔案,這時候提交得到的 commit id 會是 aece387 嗎?
再回憶規則:先將要儲存的內容透過 SHA-1 雜湊演算出雜湊值,再以這個雜湊值當作檔名來儲存。
commit 的內容包含了提交時間,所以事務上 commit id 一定不同,因為提交時間不一樣。

標籤 (Tag)

為了避免出現 detached HEAD,比較快的方式就是建立一個新分支來記錄 commit,分支會隨著每次 commit 而改變所記錄的 commit id,但是我們有時候只是為了標註一個特定的 commit,git 提供一個標籤(Tag)功能來協助我們註記特定 commit。
利用 TortoiseGit 功能選單來新增一個 tag tag1
img

亦可透過指令 git tag [tag name] 來完成。
img

開啟 \.git\refs\tags\ 可以發先多了一個 tag1 的檔案,查看內容發現跟 HEAD master 一樣都對應到 commit id ad23163
img
檢視日誌上的所有分支,可以看到 tag1 也會被描繪出來。
看起來 tag 跟 HEAD 效用應該是一樣的,不過我們切換到 tag tag1 就可以發現 tag1 被視為一個 detached HEAD,也就是它無法隨著 commit 而變更,也因為他的固定特性,所以 tag 除了拿來當作一般註解用的標籤外,也很適合拿來當作版本紀錄。
img

輕量標籤 (lightweight tag) 與 標示標籤 (annotated tag)

接著我在 master 分支在增加一個 版本號的 tag v1.0,比較特別的是這次我們加入紀錄信息。
img
從日誌的結果我們很直覺地想到如下的關聯圖。
img
但是如果我們檢視 \.git\refs\tags\v1.0 內容,可以發現 tag1 並不是指向 commit id aece387,而是一個未知的物件 ae89d11,查詢後可以發現它是 git Object 的一種類型 tag,裡面也記錄著我們加入的信息。
img
修正一下關聯圖。
img
我們可以說 tag 這個類型是為了讓我們可以在 tag 標籤上儲存更多額外的資訊而產生的,當 tag 直接對應到 commit id 時我們會說它是一個**輕量標籤 (lightweight tag),而當 tag 是透過 tag 類型間接對應到 commit id 時我們會說它是一個標示標籤 (annotated tag)**。

標籤 (Tag) 與 提交 (Commit)

切換到 tag v1.0可以發現又 detached HEAD 警告,查閱一下 \.git\HEAD 它是指向 commit id,所以說 \.git\HEAD 只會參考自 \.git\refs\heads\ 內的 HEAD,或是直接對應到 commit id。
img
這時後我們在新增一個檔案 tag_v1.0.txt 並提交,可以發現 tag v1.0 並沒有變更 commit id,但是 \.git\HEAD 卻有更新。
img
img
所以我們應該再修正一下,Commit 所做的事情:
Commit 除了會儲存當前狀態之外還會更新 \.git\HEAD,當 \.git\HEAD 是參考自 \.git\refs\heads\ 時,它會自動去更新對應 HEAD 的 commit id,當它是直接指定 commit id 時,則直接更新該 commit id。
當我們切換到任何一個 tag 時,\.git\HEAD 是直接指向 commit id,所以當然再做 commit 時 tag 不會跟著變更,所以我們也可以說 Tag 是一個固定標籤,而 Commit 則是一個會隨 commit 而變動的標籤。

後記

就像 git 的圖示一樣,目前只講到”分支”的部分,但是 git 的”合併”與”遠端同步”也是很重要的常用功能,不過這部分
重點不在於如何下指令,而是如何解決衝突,這是一個難以三言兩語說明的問題,在一知半解情況下使用可能會讓你對 git 失去信心,後續有比較好的想法時在分享。
目前也都在說明如何”建立”沒有”移除”,例如有些人希望將線圖修改的漂亮一點,移除掉一些不必要的 commit,這種”手術”其實一不小心就會把不該切除的東西也一起切除了,因此我們沒有特別說明,但是如果有概念的話,就可以知道線圖是由 commit 類型串接起來的,而 commit 類型一旦存入 \.git\objects\,”內容”就不能被變更,這個內容包含了父提交(Parent Commit),所以東西(狀態)不會不見,\.git\logs\ 內也儲存著所有 commit 的變更紀錄,在 git 內只要有 Commit 都有跡可循。