Git 分支:直覺與現實
你好!我一直在投入寫作一本關於 Git 的小冊,因此我對 Git 分支投入了許多思考。我不斷從他人那裡聽說他們覺得 Git 分支的操作方式違反直覺。這使我開始思考:直覺上的分支概念可能是什麼樣,以及它如何與 Git 的實際操作方式區別開來?
在這篇文章中,我想簡潔地討論以下幾點內容:
- 我認為許多人可能有的一個直覺性的思維模型
- Git 如何在內部實現分支的表示(例如,「分支是對提交的指針」)
- 這種「直覺模型」與實際操作方式之間的緊密關聯
- 直覺模型的某些局限性,以及為何它可能引發問題
本文無任何突破性內容,我會盡量保持簡潔。
分支的直觀模型
當然,人們對分支有許多不同的直覺。我自己認為最符合「蘋果樹的一個分支」這一物理比喻的可能是下面這個。
我猜想許多人可能會這樣理解 Git 分支:在下圖中,兩個紅色的提交就代表一個「分支」。
我認為在這個示意圖中有兩點很重要:
- 分支上有兩個提交
- 分支有一個「父級」(
main
),它是這個「父級」的分支
雖然這個觀點看似合理,但實際上它並不符合 Git 對於分支的定義 — 最重要的是,Git 並沒有一個分支的「父級」的概念。那麼,Git 又是如何定義分支的呢?
在 Git 里,分支是完整的歷史
在 Git 中,一個分支是每個過去提交的完整歷史記錄,而不僅僅是那個「分支」提交。因此,在我們上述的示意圖中,所有的分支(main
和 branch
)都包含了 4 次提交。
我創建了一個示例倉庫,地址為:https://github.com/jvns/branch-example。它設置的分支方式與前圖一樣。現在,我們來看看這兩個分支:
main
分支包含了 4 次提交:
$ git log --oneline main
70f727a d
f654888 c
3997a46 b
a74606f a
mybranch
分支也有 4 次提交。最後兩次提交在這兩個分支里都存在。
$ git log --oneline mybranch
13cb960 y
9554dab x
3997a46 b
a74606f a
因此,mybranch
中的提交次數為 4,而不僅僅是 2 次「分支」提交,即 13cb960
和 9554dab
。
你可以用以下方式讓 Git 繪製出這兩個分支的所有提交:
$ git log --all --oneline --graph
* 70f727a (HEAD -> main, origin/main) d
* f654888 c
| * 13cb960 (origin/mybranch, mybranch) y
| * 9554dab x
|/
* 3997a46 b
* a74606f a
分支以提交 ID 的形式存儲
在 Git 的內部,分支會以一種微小的文本文件的形式存儲下來,其中包含了一個提交 ID。這就是我一開始提及到的「技術上正確」的定義。這個提交就是分支上最新的提交。
我們來看一下示例倉庫中 main
和 mybranch
的文本文件:
$ cat .git/refs/heads/main
70f727acbe9ea3e3ed3092605721d2eda8ebb3f4
$ cat .git/refs/heads/mybranch
13cb960ad86c78bfa2a85de21cd54818105692bc
這很好理解:70f727
是 main
上的最新提交,而 13cb96
是 mybranch
上的最新提交。
這樣做的原因是,每個提交都包含一種指向其父級的指針,所以 Git 可以通過追蹤這些指針鏈來找到分支上所有的提交。
正如我前文所述,這裡遺漏的一個重要因素是這兩個分支間的任何關聯關係。從這裡能看出,mybranch
是 main
的一個分支——這一點並沒有被表明出來。
既然我們已經探討了直觀理解的分支概念是如何不成立的,我接下來想討論的是,為何它在某些重要的方面又是如何成立的。
人們的直觀感覺通常並非全然錯誤
我發現,告訴人們他們對 Git 的直覺理解是「錯誤的」的說法頗為流行。我覺得這樣的說法有些可笑——總的來說,即使人們關於某個題目的直覺在某些方面在技術上不精確,但他們通常會有完全合理的理由來支持他們的直覺!即使是「不正確的」模型也可能極其有用。
現在,我們來討論三種情況,其中直覺上的「分支」概念與我們實際在操作中如何使用 Git 非常相符。
變基操作使用的是「直觀」的分支概念
現在,讓我們回到最初的圖片。
當你在 main
上對 mybranch
執行 變基 操作時,它將取出「直觀」分支上的提交(只有兩個紅色的提交)然後將它們應用到 main
上。
執行結果就是,只有兩次提交(x
和 y
)被複制。以下是相關操作的樣子:
$ git switch mybranch
$ git rebase main
$ git log --oneline mybranch
952fa64 (HEAD -> mybranch) y
7d50681 x
70f727a (origin/main, main) d
f654888 c
3997a46 b
a74606f a
在此,git rebase
創建了兩個新的提交(952fa64
和 7d50681
),這兩個提交的信息來自之前的兩個 x
和 y
提交。
所以直覺上的模型並不完全錯誤!它很精確地告訴你在變基中發生了什麼。
但因為 Git 不知道 mybranch
是 main
的一個分叉,你需要顯式地告訴它在何處進行變基。
合併操作也使用了「直觀」的分支概念
合併操作並不複製提交,但它們確實需要一個「 基礎 」提交:合併的工作原理是查看兩組更改(從共享基礎開始),然後將它們合併。
我們撤銷剛才完成的變基操作,然後看看合併基礎是什麼。
$ git switch mybranch
$ git reset --hard 13cb960 # 撤銷 rebase
$ git merge-base main mybranch
3997a466c50d2618f10d435d36ef12d5c6f62f57
這裡我們獲得了分支分離出來的「基礎」提交,也就是 3997a4
。這正是你可能會基於我們的直觀圖片想到的提交。
GitHub 的拉取請求也使用了直觀的概念
如果我們在 GitHub 上創建一個拉取請求,打算將 mybranch
合併到 main
,這個請求會展示出兩次提交:也就是 x
和 y
。這完全符合我們的預期,也和我們對分支的直觀認識相符。
我想,如果你在 GitLab 上發起一個合併請求,那顯示的內容應該會與此類似。
直觀理解頗為精準,但它有一定局限性
這使我們的對分支直觀定義看起來相當準確!這個「直觀」的概念和合併、變基操作以及 GitHub 拉取請求的工作方式完全吻合。
當你在進行合併、變基或創建拉取請求時,你需要明確指定另一個分支(如 git rebase main
),因為 Git 不知道你的分支是基於哪個分支的。
然而,關於分支的直觀理解有一個比較嚴重的問題:你直覺上認為 main
分支和某個分離的分支有很大的區別,但 Git 並不清楚這點。
所以,現在我們要來討論一下 Git 分支的不同種類。
主幹和派生分支
對於人類來說,main
和 mybranch
有著顯著的區別,你可能針對如何使用它們,有著截然不同的意圖。
通常,我們會將某些分支視為「 主幹 」分支,同時將其他一些分支看作是「派生」。你甚至可能有派生的派生分支。
當然,Git 自身並沒有這樣的區分(「派生」是我剛剛構造的術語!),但是分支的種類確實會影響你如何處理它。
例如:
- 你可能會想將
mybranch
變基到main
,但你大概不會想將main
變基到mybranch
—— 那就太奇怪了! - 一般來說,人們在重寫「主幹」分支的歷史時比短期存在的派生分支更為謹慎。
Git 允許你進行「反向」的變基
我認為人們經常對 Git 感到困惑的一點是 —— 由於 Git 並沒有分支是否是另一個分支的「派生」的概念,它不會給你任何關於何時合適將分支 X 變基到分支 Y 的指引。這一切需要你自己去判斷。
例如,你可以執行以下命令:
$ git checkout main
$ git rebase mybranch
或者
$ git checkout mybranch
$ git rebase main
Git 將會欣然允許你進行任一操作,儘管在這個案例中 git rebase main
是極其正常的,而 git rebase mybranch
則顯得格外奇怪。許多人表示他們對此感到困惑,所以我提供了一個展示兩種變基類型的圖片以供參考:
相似地,你可以進行「反向」的合併,儘管這相較於反向變基要正常得多——將 mybranch
合併到 main
和將 main
合併到 mybranch
都有各自的益處。
下面是一個展示你可以進行的兩種合併方式的示意圖:
Git 對於分支之間缺乏層次結構感覺有些奇怪
我經常聽到 「main
分支沒什麼特別的」 的表述,而這令我感到困惑——對於我來說,我處理的大部分倉庫里,main
無疑是非常特別的!那麼人們為何會稱其為不特別呢?
我覺得,重點在於:儘管分支確實存在彼此間的關係(main
通常是非常特別的!),但 Git 並不知情這些關係。
每當你執行如 git rebase
或 git merge
這樣的 git
命令時,你都必須明確地告訴 Git 分支間的關係,如果你出錯,結果可能會相當混亂。
我不知道 Git 在此方面的設計究竟「對」還是「錯」(無疑它有利有弊,而我已對無休止的爭論感到厭倦),但我認為,這對於許多人來說,原因在於它有些出人意料。
Git 關於分支的用戶界面也同樣怪異
假設你只想查看某個分支上的「派生」提交,正如我們之前討論的,這是完全正常的需求。
下面是用 git log
查看我們分支上的兩次派生提交的方法:
$ git switch mybranch
$ git log main..mybranch --oneline
13cb960 (HEAD -> mybranch, origin/mybranch) y
9554dab x
你可以用 git diff
這樣查看同樣兩次提交的合併差異:
$ git diff main...mybranch
因此,如果你想使用 git log
查看 x
和 y
這兩次提交,你需要用到兩個點(..
),但查看同樣的提交使用 git diff
,你卻需要用到三個點(...
)。
我個人從來都記不住 ..
和 ...
的具體用意,所以我通常雖然它們在原則上可能很有用,但我選擇盡量避免使用它們。
在 GitHub 上,默認分支具有特殊性
同樣值得一提的是,在 GitHub 上存在一種「特殊的分支」:每一個 GitHub 倉庫都有一個「默認分支」(在 Git 術語中,就是 HEAD
所指向的地方),具有以下的特別之處:
- 初次克隆倉庫時,默認會檢出這個分支
- 它作為拉取請求的默認接收分支
- GitHub 建議應該保護這個默認分支,防止被強制推送,等等。
很可能還有許多我未曾想到的場景。
總結
這些說法在回顧時看似是顯而易見的,但實際上我花費了大量時間去搞清楚一個更「直觀」的分支概念,這是因為我已經習慣了技術性的定義,「分支是對某次提交的引用」。
同樣,我也沒有真正去思索過如何在每次執行 git rebase
或 git merge
命令時,讓 Git 明確理解你分支之間的層次關係——對我而言,這已經成為第二天性,並沒有覺得有何困擾。但當我反思這個問題時,可以明顯看出,這很容易導致某些人混淆。
(題圖:MJ/a5a52832-fac8-4190-b3bd-fec70166aa16)
via: https://jvns.ca/blog/2023/11/23/branches-intuition-reality/
作者:Julia Evans 選題:lujun9972 譯者:ChatGPT 校對:wxy
本文轉載來自 Linux 中國: https://github.com/Linux-CN/archive