碼住!史上最詳盡的Git分支管理實踐

Linux & Git 被稱爲 Linus Travis 的兩大神作, 實至名歸!

在談 Git 之前, 先談一下 Linux。

Linux 和 Windows 作爲兩個廣泛使用的操作系統, 有着極大的差異, 在各種廣泛的評價和爭執中, 我對下面的評價十分贊同 :

Linux 與 Windows 最本質的區別在哪裏。有人會說前者免費,後者需要買 (或偷)。這只是對 “free software” 的曲解。在我看來,二者最重要的區別乃是它們對自己的用戶所做的假設。

對於 Linux,這個假設是:用戶知道自己想要什麼,也明白自己在做什麼,並且會爲自己的行爲負責。

而 Windows 則恰好相反:用戶不知道自己想要什麼,也不明白自己在做什麼,更不打算爲自己的行爲負責。

我不曉得上述觀點最初源自哪裏, 或許是這裏: Zaikun's Blog

我不是一個極端主義者, 這兩種理念沒有誰是誰非, 孰優孰劣。 海納百川, 有容乃大, 我會嘗試發現每一種理念的優勢和適用場景, 而不是一味地去否定什麼。

在工作場景上, 我更喜歡 Linux 的理念 :

我曾rm -rf誤刪過/etc目錄, 導致系統陷入癱瘓; 也曾因包管理依賴問題而導致軟件損壞 。。。

在我看來, 這些都不可怕, Linux 會準確的向我展示故障原因, 而不是請稍後。。。, 我們正在做一些準備工作

這裏故障原因是指一個基於廣泛認知基礎上的解釋。 比如: 如果因爲代碼編寫的失誤, 導致一段程序沒有按照設計意圖執行, 我只需瞭解代碼層面的邏輯錯誤即可, 而不是深究錯誤的代碼在電路層面導致了什麼樣的問題發生

日復一日的使用, 我犯錯誤的概率越來越低, 對 Linux 本身的理解越來越深入, 對 Linux 越來越信任, 並且逐漸有了一種對 Linux 的掌控感。

然而在遊戲娛樂場景上, 我更欣賞 Windows 的理念:

當我想玩遊戲放鬆一下時, 我希望 Windows 爲我包辦一切, 我要做的就是雙擊運行, 然後開始遊戲。

Git 與 Linux 同源同宗, 亦是有着相似的理念, 其本身有着極爲靈活的設計, Git 認爲:

用戶知道自己在做什麼, 並且會爲自己的行爲負責。

在開源領域的廣泛使用中形成了三種被廣泛接受的最佳實踐: Git flow, Github flow, Gitlab flow, 可以參考 Git 工作流程 - 阮一峯 一文。

從手忙腳亂開始

當我初學 Git 時, 我關注 Git 的工程實踐勝過其內在的設計理念, 以至於迫切的去尋找一些所謂的最佳實踐, 然後僵硬地模仿甚至生搬硬套, 結果顯而易見, 我始終無法做到

flow,原意是水流,比喻項目像水流那樣,順暢、自然地向前流動,不會發生衝擊、對撞、甚至漩渦。

理想是行雲流水, 現實卻往往慘不忍睹

靜下心來想一想

收起急功近利的心態, 我開始思考, Git 的設計理念到底是什麼。

Git 是一種版本控制系統, 先不談 Git 是如何設計的, 如果讓我來設計一個版本管理系統, 該如何下手?

✏︎ 設計一個最簡單的版本控制系統

這就是一個簡單粗暴的版本控制系統, 簡單的文件拷貝加重命名已經能滿足對於畢業論文的版本控制, 到最後, 能拿出一個漂亮的畢業論文終板即萬事大吉。

上面的每一個版本都是基於上一個版本修改而來的, 並且當新的版本出來之後, 老舊版本的價值就幾乎不存在了, 在使用 SVN 或者 Git 一個人開發小項目或記筆記的時候, 場景與此類似。

✏︎ 如果場景複雜一點兒呢?

如果導師幫我一塊改, 都基於畢業論文最終版1。doc修改, 導師改出了C。doc, 我改出了D。doc, 這時若想保留兩人所有的修改, 併合並出一個新的版本E。doc, 似乎就要花些功夫了。

首先要找出來導師改了哪些, 我改了哪些;

然後基於畢業論文最終版1。doc, 把導師的修改和我的修改應用過來;

如果導師的修改和我的修改是在不同地方修改的, 那麼互不影響, 分別應用;

如果導師的修改和我的修改在同一處, 要選擇以導師的爲準, 還是以我的爲準;

即使導師的修改和我的修改不在同一處, 但是否會造成整體邏輯的矛盾, 要從整體上修正邏輯。

哈! 這不就是 git merge 嘛!

不好意思, 圖放錯了

✏︎ 關注點到底在哪裏?

導師的加入使得我們簡易的畢業論文版本控制系統變得有點兒力不從心, 我們必須小心處理導師的修改與我的修改對論文本身造成的影響, 如果又來一個熱心學長同時對我的論文加以指導(修改), 問題似乎變得更加複雜了

不知不覺中, 我們的關注點已經從論文本身轉向了修改, 多人同時進行修改使得我必須小心處理每個人的修改, 不能遺漏, 不能衝突, 也不能邏輯矛盾, 這簡直太混亂了

Git 的設計理念

✏︎ 理解 Commit

通過對上面例子的分析, 相信你已經體會, 論文版本控制系統的核心關注點應該是修改, 而不是論文本身。

讓思路回到 Git 上來, Git 分支圖中的每個點由git commit命令產生, 並且會產生一個唯一的sha1值, 因此可以通過sha1值來唯一確定一個提交點。

在上圖中, B點應有兩種含義:

表示一個快照, 即項目工程所有文件在這一刻的狀態;

表示一個差異, 即B狀態與A狀態文件的差異, 亦稱作補丁(patch)。

類比於畢業論文, 快照也就是畢業論文最終版1。doc論文本身, 差異也就是修改。

如何體現B點的這兩個屬性? (我們用[B]來表示B點的sha1值)

回到B點的快照: git checkout [B]

查看B點與上一個提交點的差異: git show [B]

使用git checkout命令我們可以在整個 Git 提交歷史上的所有快照版本穿梭, 你可能聽過說HEAD指針, git checkout正是通過挪動HEAD指針來達到快照切換的目的, 如果多次穿梭後, 你迷失了自己, 找不到當前在哪一個快照, 請查看 Git 分支圖, 找到HEAD指針, 這就是你所處的快照版本。

可以看到, git show命令完整的展示了B點與其上有節點A點的差異, Git 作爲一個面向源碼的版本控制工具, 將差異以行爲基本單位表示是比較合理的一個選擇。 這也意味着將 Git 用於非文本資源的版本控制工具或許不是最佳選擇。

對一個提交點含義的雙重解釋看上去很不錯, 不過, 在這個分支圖上, E點有點兒特殊, 只有E點有兩個上游節點C和D, 嘗試執行git show [E], 發現並沒有像其他節點一樣, 顯示出diff信息, 這說得過去, 不然到底該顯示E和C的差異, 還是E和D的差異呢?

這時就只能藉助 Git 的另一個命令git diff [X] [Y]來顯式聲明要比較任意兩個節點X和Y的差異。

磚頭有了, 城堡在哪呢?

✏︎ 日常一天

在一些項目組裏, 你可能會被告誡道: "記得每天下班前提交下你的代碼。" 也許他們已經發現: "怎麼代碼又衝突了", "我寫的代碼怎麼被覆蓋了", 會對你多提一句告誡: "記得提交前先拉一下代碼, 別把同事寫的覆蓋了"。 於是, Git 就僅僅成爲了一個遠程代碼倉庫。

產品: "上次提的3個需求, 今天就上1個, 另外2個不用了"

開發: "我代碼昨天都寫完提交了, 那隻能把2個需求代碼刪掉了。 我可是有代碼潔癖的, 不能讓我的項目裏這麼多無用代碼留着"

[兩小時後]

產品: "我想了一下, B功能還是要上的, 一共上2個功能"

開發: "行吧, 我再把代碼拷貝回來"

[臨上線]

產品: "不行, 下掉B功能, 上C功能! 快!!!"

開發: "W-- 我佛慈悲!"

[上線後]

老大: "C功能有bug, 立刻回滾"

開發: "好, 我退回到上次發版的快照"

這是日常的一天, 也是糟糕的一天, 大把的時間浪費在代碼的刪除和拷貝上, 而不是在創造上。

✏︎ 問題出在哪了?

上節我們提到, Git 每個 Commit 都有兩種屬性, 快照和補丁。 在上面的使用場景中, Git 只發揮出了不到一層功力, 大家關注的僅僅是最新提交點的快照, 當然, 這個快照是極爲重要的, 重要到我們的HEAD指針幾乎總是在指向他, 重要到我們會把他稱爲最新的master分支。

我們把關注點轉移到 Git 的補丁屬性上來, 你每天提交的 commit 代表着你這一天的工作成果, 那麼描述怎麼寫?

"張三20190622工作"? 還是 "增加了A功能, B功能, C功能寫了一半"

或許後者稍微好一點兒, 至少在幾天時候查看 Git 提交記錄時能一目瞭然的知道這次提交包含什麼修改。

還記得git diff的輸出嗎? 是行級的差異。 爲什麼不是文件級別, 或者字符級別? 每次代碼提交以天爲單位真的合適嗎? 當然不合適, 每個 commit 的最佳粒度應該是相對獨立的特性(feature), 比如上文提到的A, B, C三個功能。

理想情況下, A, B, C是三個獨立的功能, 分別作爲三次 commit。

✏︎ 更好的做法是什麼?

還記得嗎? A, B, C都是獨立的補丁(patch), 那麼A, B, C的次序是沒有關係的, 也就是說C1, B2, A3的代碼快照應該是一樣的。 不信試一下, 可以用git diff驗證結果。

當要求撤掉 B 功能時, 如果可以直接刪掉 B 這次提交, 那麼瞬間就達到目的了。 但是, 有兩點是需要考慮的:

1、一般來說, 大家同時使用的分支只前進, 不後退, 即不能篡改歷史;

2、若真的篡改了歷史, 那麼 B 功能的代碼就從提交記錄上消失了, 萬一需要再次添加 B 功能, 這將是悲劇。

我們可以換一種思路來達到相同的目的: 構造一個補丁, 該補丁B完全相反, 即把B增加的行刪除, 新增B刪除的行。 當然, 這一切都是自動的, 只要使用git revert [B]命令, 即可創建一個B的反向提交。 顯然, -B1和C2的快照狀態是一致的, 可以用git diff命令驗證。

當要求把 B 功能加回來時, 是該祭出神器了嗎?

當然不是, 我們可以再製作一個-B的反向補丁--B, 負負得正嘛。 不過這看起來怪怪的, 如果能複製一份B補丁重新打上就好了, git cherry-pick [B]正是我們要找的答案。 顯然, --B1, B2, C3的快照狀態必然是一致的。

注意: 通過git cherry-pick複製的B和原有的B有不一樣的sha1, 即便這兩個 commit 的內容相同。

既然這三種狀態是等價的, 那麼作爲傾向於完美主義的我們, 更希望在 Git 提交歷史上留下的是最後一種乾淨的狀態。 但我已經在B2狀態了, 怎麼才能實現C3? 相信你已經想到了辦法, 回到最初的檢出點, 通過cherry-pick拾取A, B, C3個補丁, 即可創建一個乾淨的提交歷史。 或許你還聽說過git rebase, 這是一個非常強大的命令, 我們會在後文討論。

✏︎ 重新認識分支

當提出A, B, C三個需求的時候, 如果分派給三個人, 每個人負責一個功能, 同時基於最新的代碼開發, 那麼將會進入這種狀態

但是, 如果我們遵循master, develop分支模型開發, 那麼永遠不會在 Git 分支圖上看到這種狀態。

我們終於討論到分支了, 或許你已經發現, 大家在談論 Git 的時候, 分支似乎是最重要的事情, 幾乎三句不離分支; 而我們說了這麼多, 還沒有提及分支這件事; 上面所有的插圖中, 儘管我把他稱作分支圖, 卻沒有分支標記, 這並不影響我們對 Git 的理解。

再次重申一下, 我們的關注點是commit, 用唯一的sha1標識, 他有兩種含義快照和補丁。

但是, sha1不是一個好記的標識, 我們需要給一些重要的commit別名。 前面我們已經提到了HEAD指針, 他指向當前的commit, 這就是一個標識。 除此之外, Git 還有兩種重要的標識, 分支(branch)和標籤(tag)。

分支和標籤是某個commit的別名, 因此, 在 Git 命令中可以使用分支和標籤來代替commit的sha1值。 比如切換到某個分支, git checkout [branch-name], 其實就是切換到了這個commit點的快照。

使用分支切換和使用sha1切換會有一些差異, Git 會維持一個 Context, 記錄了當前激活的分支, 如果你的命令提示符上有 Git 分支的標識(macOS終端默認有該標識), 將會看到這種差異。

分支和標籤都可以作爲任何一個commit的標識, 他們區別在於:

1、分支(branch)具有前進功能, 可以前進到下游commit節點上;

2、標籤(tag)僅僅綁定在一個commit, 主要應用場景是作爲版本發佈的標識。

我們主要討論分支(branch)。 分支怎樣前進呢?

1、當執行git commit後, 分支就前進了;

2、執行git merge後, 分支會前進。

當 Git 關聯到遠程倉庫時, 每個分支可以設置一個遠程追蹤分支git branch --set-upstream-to=[origin]/[branch], 當執行git fetch, git pull, git push時, 默認都是在操作關聯的遠程分支。 一個本地 Git 倉庫可以關聯多個遠程倉庫, 習慣上默認倉庫或者主倉庫叫做origin。

當本地master分支落後遠程origin/master分支時, 一般會執行git pull命令跟進, 但這後面到底發生了什麼?

git pull命令其實是個git fetch和git merge的組合命令, git fetch是僅僅拉取遠程分支的進度, 上圖這種狀態, 遠程origin/master超前了本地master, 必然是執行了git fetch後才能看到, 一般支持 Git 的圖形工具或者 IDE 會在後臺定期做這項工作, 在遠程分支更新後及時通知。

當HEAD指針在master時, 執行git merge origin/master, master即會前進到origin/master。

Merge 不是合併分支嗎? 怎麼變成了分支前進?

✏︎ 危險的 Merge

我把git merge定義爲高危操作! 一般開發人員(非項目leader)應儘可能避免使用直接或間接使用該命令。

提到 Merge, 或許下面的這種場景是我們第一時間想到的:

當我處在master時, 也就是HEAD指針指向master, 執行git merge iss53: 若無衝突, 即會得到下圖結果; 若有衝突, 則會提示手動解決, 然後作爲一次新的 commit, 同樣也會得到下圖結果。

也許你發現了, 這裏分支圖風格變化了, 不僅僅是畫風的轉變, 最重要的是箭頭方向。 這兩張圖是我從 Git 官方文檔複製過來的, 所以請不要質疑他的權威。 那麼是我之前的箭頭方向畫錯了嗎?

有句話怎麼說來着? 權威就是用來質疑的! 不過質疑之前, 我們先嚐試理解。

1、當箭頭由上游節點指向下游節點, 就像我最初的插圖那樣。 從整個分支圖上, 我們能看到因爲團隊的努力, 分支正在前進, 項目正在進展。 也就是說, 更符合宏觀上的趨勢;

2、當箭頭由下游節點指向上游節點, 就像官方文檔的插圖那樣。 還記得每個commit的含義嗎? 快照和差異, 是該節點與其上游節點的差異, 所以在 Git 內部存儲時, 每個commit一定會保留一個指針, 指向其上游節點。 也就是說, 這樣的設計更能體現 Git 的內部設計。

好了, 我們該關注 Merge 到底做了什麼:

1、構造一個節點C6, 這個節點將會有兩個上游節點: C4, C5;

2、將分支master由C4移動到C6。

這看起來沒有什麼難的, Git 的diff功能會自動幫我們計算差異, 剩下的工作也是 Git 默默幫我們完成的。 但是, 你還記得我們的論文版本控制系統嗎?

1、如果C4和C5對同一個行做了修改, 該取哪個呢? 取了C4的, 那麼C5其他代碼還能工作嗎? 或者反之。 又或者兩者都不能取, 而應該重寫這行代碼, 以兼容兩者的修改。

即便他們修改的地方互不交叉, 那麼會不會照成整體上的邏輯錯誤呢? 比如C4修正了一個成員變量的拼寫錯誤, C5在增的代碼中還在引用原有的變量名, 這時構造C6時並不會有任何衝突提醒, 但構造出的代碼卻是無法通過編譯的。

2、或許你已經習慣, 每當我們遇到問題時, Git 幾乎都能給我們提供自動化的解決方案。 比如: 當需要對比差異時, 可以使用git diff; 當需要製作反向補丁時, 可以使用git revert; 當需要複製補丁時, 可以使用git cherry-pick。 那麼, 現在這種場景, Git 有什麼命令能幫助我們呢? 很遺憾, 沒有, Git 能給我們的僅僅是當出現行級衝突時, 給我們一個 conflict 提示, 除此之外, 只能靠我們來發現和解決了。

我們相信, 在你或你的同事提交C4, C5時, 他們都是一個可以工作的版本, 至少應該能夠正常編譯和通過測試用例。 但是如果存在我們描述的第二種場景, 合併C6時沒有衝突, 但卻無法通過編譯。

以上正是我把 merge 操作定義爲高危操作的原因。

既然 Git 不能給予我們幫助, 那必須要尋找緩解 merge 帶來的潛在危險的措施了。

一個方法是把危險拋給更有經驗的人的。 就像本節開始提到的那樣, 一般開發人員(非項目leader)應儘可能避免使用直接或間接使用該命令。 他們踩過更多的坑, 在合併分支時會考慮的更多更全面, 並且他們將對本次合併的成果(即新的 commit, 就像上圖中的C6)負責。

計算機工程中最不可靠的部分是人件。 再細緻的人也有犯錯的時候, 並且相比於計算機來說, 這個概率要遠遠高的多, 因此還應該引入自動化測試機制。 比如持續集成(CI), 每當一次合併結束後, 自動觸發編譯和測試, 併發送測試報告。

總是把這些風險推給有經驗的人, 這是不公平的。 況且, 作爲經驗欠缺的我們, 沒有機會處理風險, 我們怎麼積累經驗呢? 最重要的是, 我們能做的僅僅是事後補救嗎? 能不能從根源上避免這種風險?

我們來看一下另一種 merge 場景:

(插圖風格又換了, 這次的插圖來自 猴子都能懂的 Git 入門

bugfix分支從master檢出, 很幸運, master分支還沒有更新。 這時, 將bugfix合入master。

我們首先讓HEAD指針指向master, 然後執行git merge bugfix --no-ff, 分支圖將會變成這個樣子。

沒有意外, 這根我們上面對 merge 行爲的描述是一樣的: 構造一個新的 commit 節點C, 其上游節點分別爲B和Y, 然後將master分支標籤指向C。 相較於上面的場景, 這種情況下構造C是一定不會產生衝突的。 爲什麼?

我們從 commit 的補丁屬性入手, 把B->C看成一個補丁, 那麼我們對 merge 動作的期望結果應該是B->X和X->Y兩個補丁累計作用。 也就是說:

B->C = B->X + X->Y (1)

但是從圖中, 從B到C有兩條路徑, 一條是直達, 另一條是分步:

B->C = B->X + X->Y + Y->C (2)

那麼Y->C呢? 若想讓我們的期望(1)和事實(2)都成立, Y->C必須是是一個空補丁, 也就是說, C和Y的快照狀態是完全一致的, 可以用git diff [C] [Y]驗證一下我們的推論。

爲什麼要有這個空補丁, 直接將使用Y節點不行嗎? 當然可以!

觀察一下我們的 merge 命令, 有一個附加參數--no-ff, 這個參數強制關掉了 fast-forward 特性。 如果我們不添加這個參數, 直接只用git merge bugfix, 那麼得到的結果將是這樣的:

master直接被指向了Y節點。 還記得嗎, 讓分支前進的第二種方法是什麼來着? git merge, 這不是就例子嘛!

執行git merge [X]動作時, 若無需構造新的 commit 節點, 直接將當前分支標籤前進到要X節點, 這就是所謂的 fast-forward 特性。

這種情況下的 merge 動作讓風險大大降低。 首先 commit X, Y的提交者要對兩次修改負責, 他們有責任保證每次提交後的代碼是可以通過編譯和測試的; 其次, 項目負責人在將bugfix分支合入master之前, 只需確保Y的快照版本是正確的, 因爲 merge 動作將不會帶來任何再次的變更, 只是將分支前進到Y的快照, 這大大降低了 merge 的風險。

再次提醒, 慎用git pull, 這條命令隱含了git fetch, git merge兩條命令。 一個更好的做法是先git fetch獲取遠程分支狀態, 當你確認本地關聯的分支能與遠程分支以fast-forward合併的時候, 再執行git merge或者git pull。

建築理想的城堡

✏︎ 理想的分支圖

我們已經找到了一種來儘量避免 merge 風險的場景, 在這種場景下, 我們會構造出怎樣的分支圖?

如果使用 fast-forward 特性, 結果將是這樣:

(該圖是 RedHat 旗下 debezium 項目的分支圖, 我工作中重度使用該項目, 在其基礎上做了很多二次開發, 並且有 bugfix 被合入主線分支)。 Github 傳送門

如果我們使用git merge --no-ff參數, 結果將是這樣的:

(該圖來自掘金文章: 如何優雅地使用 Git)

看到區別了嗎? fast-forward 結果將會是一條一線, 這是最乾淨整潔的分支圖, 但是相應的, 我們已經無法一目瞭然的區分出哪幾個 commit 構成一個功能, 必須通過規範的註釋(比如上圖中全部以 JIRA 編號開頭)來做分區; 而--no-ff參數雖然讓分支圖變得看上去複雜了一點兒, 但卻非常直觀地保留了 commit 集合和功能的對應關係。

兩種方式哪個更好? 像文章最初說的那樣, 我不是一個極端主義者, 兩種各有優劣, 要分場景對待。

對於超大規模的開源項目來講, 每一個 commit 都不是隨意的, 必須要有 JIRA, 郵件列表, Github Issue 列表等諸如此類的討論, 明確 commit 的功能和影響, 確保每個 Commit 只做一件事, 變動最小化, 然後通過 Pull Request 方式請求合併至主倉庫的主線分支。 在這種情況下, 使用--no-ff的話, 幾乎每個 commit 都會產生一個空的 merge 節點, 分支圖就變成了鋸齒狀, 帶來的收益微乎其微; 而規範 commit 註釋, 並且使用 fast-forward 或許是一個更好的選擇。

對於需要快速響應變化的互聯網公司來說, 每一次改動之前都先建立 JIRA 或者 Issue, 這幾乎不太現實, 通過--no-ff的節點加上相對簡潔明瞭的註釋可能是一個更明智的選擇。

✏︎ 現實與理想的差距

但多數情況下, 現實場景並不滿足這樣的狀態, 因爲項目不是一個人在開發, 在我們提交的同時, 別人也在提交, 當我們的分支準備合入master時, master已經前進了, 又回到了最初那種糟糕的狀態。 是去面對糟糕的狀態, 還是避免糟糕的狀態, 想辦法修正它?

✏︎ 向理想靠攏

如果我們在向主分支合入之前, 把這兩個commit通過git cherry-pick命令嫁接到最新的 master 分支上, 看起來一切都變好了。 當然, X'和Y'會被視作全新的commit, 他們都會有新的sha1。

不過這裏有個問題, 前文提過, 分支(branch)是一個可以向前滑動的標籤, 從Y到Y'似乎不能直接前進, 我們的分支標記怎麼才能轉移到Y'上呢?

一個粗暴的方法是, 我們可以先刪掉bugfix分支, 然後從Y'創建它。 不過, Git 也提供了將分支標籤指向任意commit節點的命令, 即git reset。

當HEAD指針指向bugfix(Y)分支時, 執行git reset --hard [Y'], 會將HEAD指針指向bugfix分支同時指向Y'。 (參數--hard會清空工作區和暫存區, 此外還有--mixed, --soft選項, 會對工作區和暫存區有不同的影響, 如果你不瞭解, 也許你需要尋找其他的教程, 本文不討論這些)

爲了達到這種理想的分支狀態, 我們要經常這麼幹, 這一切工作似乎變得有點兒繁瑣, 要執行這麼多步驟才能達到分支嫁接的目的。 對的, Git 爲我們提供了自動化方案, 那就是強大的 rebase。

Rebase 譯作變基, 從字面上理解, rebase 命令可以改變當前分支的基點, 我們現在僅關注 rebase 功能其中的一個特性, 來達到我們分支嫁接的目的就足夠了。 回到最初的場景, bugfix 分支還指向Y, 這是我們只要執行git rebase master, 即可達到目的。

我們本地的bugfix已經變基完成, 若它已經關聯過遠程分支, 那麼origin/bugfix還處在Y, 我們要把本地的狀態變更推送到遠程, 如果接着執行git push, 將會報錯:

可以看到, Git 服務器拒絕了我們的推送請求, 並返回了一些提示信息, 或許看到這場面, 你一下就慌了, 我辛苦寫的的代碼不會丟掉吧! 提示裏面有git pull命令, 我是不是應該執行, 挽救一下!

當真正執行了git pull命令後, 這纔是糟糕的場面!

別忘了, git pull暗含git merge語義, 這會導致一次合併, 構造的一個新的 commit Z, 上游分別是 bugfix Y'和 origin/bugfix Y, bugfix 指向了Z。 如果這時再執行了git push命令, 那麼這糟糕的分支圖就推到了服務器上, 整個團隊將會看到你把分支圖搞亂了, 這畫面簡直不可描述! (如果你腦補不出來這時分支圖的樣子, 下個實操案例中會演示)

記住, 不要慌, 你已經瞭解了 Git 的原理, 你有能力掌控 Git, 而不是被一兩個莫名的錯誤嚇退了。 還記得剛剛使用的git reset命令嗎? 他可以把分支強制指向任一commit, 我們使用git reset --hard [Y'] 不就回到剛纔的狀態了嗎?

好了, 假裝剛纔什麼都沒發生, 我們仔細看看服務器返回的錯誤, 並且思考一下問題到底出在哪裏?

首先, git push到底在做什麼? pull 和 push 是一對反義詞, git pull是把遠程分支進度同步到本地, 然後嘗試將遠程分支合併到關聯的本地分支; git push在做類似的事情, 不過是相反的, 他會先把本地分支同步到遠程, 然後嘗試將本地分支合併到關聯的遠程分支。 但是, 當無法滿足 fast-forward 條件時, git push會直接報錯, 而不是嘗試構造一個新的commit。 這就是我們剛剛遇到的錯誤場景。

但很顯然, 我們在本地調整了分支, 並且期望把調整後的狀態推送到遠程, 覆蓋遠程分支原有的狀態。 這時需要添加一個參數git push --force, 強制覆蓋遠程關聯分支。 現在遠程的 bugfix 分支和本地 bugfix 保持同步了, 都指向了Y', team leader 可以 review 代碼, 然後合入 master 了。

✏︎ 對主分支保持敬畏

上面的git rebase, git push --force看起來很有效果。 但是, 這在協作中似乎會照成一個問題: 如果大家都在 force push, 那豈不就亂套了?

所以, 應該制定一個約定: 公共分支不允許 force push。 也就是說, 公共分支只能前進。

在常用的 Git 服務器上, 比如碼雲, GitLab, Github都支持分支保護功能, 我們至少要設定一個保護分支(以 master 爲例), 作爲功能分支。 該分支應該有以下特性:

只能前進, 也就是不允許 force push;

不允許直接 commit, 只能通過 merge 動作使分支前進;

收緊 merge 權限, 只允許部分高級工程師執行 merge;

只允許 merge 滿足 fast-forward 條件的 commit;

每次 merge 前, 必須進行 code review 和持續集成(CI);

Commit 提交者, code review 者, merge 者都要對代碼變更負責。

在這種模式下, 所有團隊成員以 master 分支爲核心進行開發。 每個人接到開發需求後:

1、從最新的遠程 master 分支檢出自己的開發分支;

2、開發;

3、開發結束後, 以最新的遠程 master 爲基點, 執行 rebase 操作, 解決掉衝突;

4、向有 merge 權限的人提交合並請求(碼雲和 Github 稱作 Pull Request, Gitlab 稱作 Merge Request)

5、Code review 和 CI;

若第5步通過, 提交被合併, master 前進; 否則回到第2步;

已被合入的開發分支生命週期結束, 被刪除。

關於第7步, 你沒看錯, 一個分支的生命週期就是這麼短暫! 這取決於一個特性的大小, 可能只有幾分鐘, 或許有幾天, 而不是像 master 分支一樣永遠存在。

每個人在開發過程中都應該有自己的分支, (我推薦以你的名字結尾, 這樣便於標識), 這條分支是你的私有分支。 你應該對 master 分支保持敬畏, 但對於你的私有分支, 你可以任意的 force push, rebase, 甚至你不把他放到項目的公有倉庫, 放到自己 fork 的私有倉庫裏, 這就是一張草稿紙!

✏︎  讓我們篡改歷史吧!

在我們自己的分支(草稿紙)上, 我們可以相對隨意地修改, 但是當提交 PR 時, 必須整理出一份乾淨整潔的提交記錄, 這必然涉及到 commit 歷史的修改。 還記得上文提到的一個強大命令嗎? 對的, 就是git rebase!

在macOS終端上通過git log --oneline --graph --all可以打印出上面的分支圖, 這是我最常用的一個命令, 在linux上的表現行爲可能會有點兒區別, 或許你可以嘗試git log --oneline --graph --all --decorate=short | less -r, 或者參考git log --help進行調整, 來達到你想要的打印效果。 當然, 使用圖形軟件查看分支圖也是一個很好的選擇。

看, 我在開發一個訂單功能, 當我開始開發的時候, master 在c80dc1e這個提交點, 我通過git checkout -b feature-order-pancheng檢出一個自己的開發分支。

我在開發過程中, 做了7次 commit, 但事實上只有4個是有意義的, 其他的幾個僅僅是我在提交後立刻就發現了很明顯的錯誤, 然後修正過來了, 這看起來就是個草稿, 如果同事 review 我的代碼, 看到如此低級的錯誤, 似乎不太好。 這裏最好的做法就是篡改 Git 提交歷史, 把 fix 類型的 commit 與上一個 commit 合併。

我們現在執行git rebase -i c80dc1e, -i代表交互模式:

進入了一個 vim 界面(也可能是 nano, 取決於你配置的默認編輯器), 上面列出了我們的每次提交。 注意, 這裏是從上往下排列的, 上一個分支圖中時從下往上排列的, 在不同的命令或軟件中, 方向可能不一樣。

每個 commit 最前面都是 pick 命令, 這就與我們前面使用的 cherry-pick 命令作用相似, 下面有對所有命令的解釋, 你可以自行嘗試。

我們看到, 有一個 fixup 命令似乎正是我們想要找的:

保存退出, 再次查看分支圖:

哈! 我們的黑歷史在本地的 feature-order-pancheng 分支被抹掉了! 然後把它推送到遠程。

不出意外, Git 服務器拒絕了我們的推送請求, 因爲不滿足 fast-forward 條件。 現在你應該不會慌了吧! 我們假裝慌一把, "根據提示"執行git pull:

哈! 雙份提交! 被老大看見說不定要挨批的! 還記得這時候應該做什麼嗎? 先回到 merge 前的狀態, 執行git reset --hard 395ef39:

然後執行git push -f:

之前的 origin/feature-order-pancheng 分支所處的點從圖上消失了, 我們還有可能找回他嗎? 哦對了, 分支名只是個標籤而已, 我還記得那個點之前的sha1是ccce49d, 執行git checkout ccce49d, 分支又回來了, 原來只是隱藏了! 我們把這種沒有任何標籤的分支稱謂遊離分支, 他默認不會在分支圖中顯示, 並且會在一段時間後由 Git 進行垃圾回收, 纔會真正的消失, 在此之前, 我們可以通過git reglog找到他們的sha1, 回到那個快照。

訂單功能開發好了, 可以向主分支提合併請求了, 哦, 對了, master 已經前進了, 我們提 PR 之前必須先跟進。 執行git rebase master, git push -f, 然後再查看分支圖:

這時就可以去提交 Pull Request 了。

當 PR 通過後, 你的分支將被合入 master 分支, 執行git fetch拉取遠程分支信息, 然後查看分支圖:

嗯, 一次愉快的開發結束了。

如果大家都遵守這個約定, 那麼我們的分支圖將會是這樣:

雖然我們在 master 分支合併上使用了--no-ff方式, 但是它等價於是一條直線, 這對 code review 和協作開發將十分友好。

✏︎ 那麼發版呢?

相比於往 master 上 merge 提交, 項目發版是一個更謹慎的話題。

我們上面已經提到持續集成(CI), 這是一種自動化的打包和測試機制, 往往會與持續交付(CD)一起協作。 我們可以將 Git 的某些行爲作爲 CI/CD 的觸發條件, 來達到自動化打包, 測試, 部署的能力。

我們對分支做以下規範:

master 主功能分支;

feature-xxx-[developer name] 特性開發分支;

fix-xxx-[developer name] 非緊急bug修復分支;

hotfix-xxx-[developer name] 線上緊急bug修復分支;

dev-[date] 開發環境發佈分支(或tag);

test-[date] 測試環境發佈分支(或tag);

uat-[date] 準生產環境發佈分支(或tag);

release-[date] 線上發佈分支(或tag)。

在 Git 服務器中, 幾乎都會提供 CI/CD 功能, CI/CD 觸發條件根據正則表達式匹配branch或tag, 自動觸發項目的編譯, 打包, 測試, 部署等行爲。

在分支管理中, dev-[date]分支可以由任意開發人員隨時檢出發佈到開發環境聯調; test-[date], uat-[date], release-[date]原則上必須從master上逐級檢出, 分別測試, 若發現問題, 進行 bugfix。

看, 我們從75a8e22檢出test-20190623分支, 當推送到服務器上時, CI/CD 會自動觸發, 最終項目被部署到測試服務器上。 我們在測試上發現一個 bug, 在真正上線前發現的 bug 總比上線後好。 bugfix 後, 我們認爲沒有問題了, 檢出release-20190623分支, 觸發 CI/CD, 部署到生產環境。 半天后, 我們發現一個緊急的線上 bug, 我們緊急創建了 hotfix 分支, 在 CI 通過後, 將其合入到release-20190623分支, 然後刪除 hotfix 分支。

看上去這次發版成功了, 那麼這兩個 bugfix commit 怎麼合入到 master 呢?

還記得我們說 master 分支的 merge 原則嗎? 只允許 merge 滿足 fast-forward 條件的 commit。 在我們開始測試後, master 已經前進, bugfix commit(即在test-[date], uat-[date], release-[date]上的 hotfix) 就不能直接合併到 master, 並且發佈點 rebase 是有風險的, 這時就只能通過 cherry-pick 來把補丁手動打回到 master 分支上了!

我們從最新 master 切出一個 fix 分支, 並把兩個補丁通過 cherry-pick 移植過來:

接下來就是 PR 流程, 當合入 master 後, 刪除該 fix 分支:

嗯, 這篇文章前後大約寫了一個禮拜, 是時候提 PR 了, 我要去 rebase 了

本文爲碼雲最佳實踐徵文大賽優勝獎文章,除此篇以外,我們還收錄了很多優秀的最佳實踐文章,希望能夠爲您帶來一些參考:https://gitee.com/oschina/gitee_best_practices雖然有獎徵文活動已經結束,但我們仍在徵集更多的優秀實踐文章,如果你也有最佳實踐想要分享,歡迎繼續在本倉庫投稿~

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章