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