Git 分支管理實戰

從手忙腳亂開始

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

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

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

Git Flow

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

輸入圖片說明

靜下心來想一想

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

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

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

版本控制 - 畢業論文

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

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

如果場景復雜一點兒呢?

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

  1. 首先要找出來導師改了哪些, 我改了哪些;
  2. 然后基于畢業論文最終版1.doc, 把導師的修改我的修改應用過來;
  • 如果導師的修改我的修改是在不同地方修改的, 那么互不影響, 分別應用;
  • 如果導師的修改我的修改在同一處, 要選擇以導師的為準, 還是以我的為準;
  • 即使導師的修改我的修改不在同一處, 但是否會造成整體邏輯的矛盾, 要從整體上修正邏輯.

哈! 這不就是 git merge 嘛!

git merge 事故現場

不好意思, 圖放錯了

輸入圖片說明

關注點到底在哪里?

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

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

Git 的設計理念

理解 Commit

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

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

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

  1. 表示一個快照, 即項目工程所有文件在這一刻的狀態;
  2. 表示一個差異, 即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

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

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

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

磚頭有了, 城堡在哪呢?

日常一天

在一些項目組里, 你可能會被告誡道: "記得每天下班前提交下你的代碼." 也許他們已經發現: "怎么代碼又沖突了", "我寫的代碼怎么被覆蓋了", 會對你多提一句告誡: "記得提交前先拉一下代碼, 別把同事寫的覆蓋了". 于是, 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的反向提交. 顯然, -B1C2的快照狀態是一致的, 可以用git diff命令驗證.

輸入圖片說明

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

程序員專用鍵盤

當然不是, 我們可以再制作一個-B的反向補丁--B, 負負得正嘛:laughing:. 不過這看起來怪怪的, 如果能復制一份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 命令中可以使用分支標簽來代替commitsha1值. 比如切換到某個分支, git checkout [branch-name], 其實就是切換到了這個commit點的快照.

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

分支標簽都可以作為任何一個commit的標識, 他們區別在于:

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

  • 標簽(tag)僅僅綁定在一個commit, 主要應用場景是作為版本發布的標識.

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

  • 當執行git commit后, 分支就前進了;
輸入圖片說明
輸入圖片說明
  • 執行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 fetchgit merge的組合命令, git fetch是僅僅拉取遠程分支的進度, 上圖這種狀態, 遠程origin/master超前了本地master, 必然是執行了git fetch后才能看到, 一般支持 Git 的圖形工具或者 IDE 會在后臺定期做這項工作, 在遠程分支更新后及時通知.

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

Merge 不是合并分支嗎? 怎么變成了分支前進?

危險的 Merge

我把git merge定義為高危操作! 一般開發人員應盡可能避免使用直接或間接使用該命令.

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

輸入圖片說明

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

輸入圖片說明

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

有句話怎么說來著? 權威就是用來質疑的! 不過質疑之前, 我們先嘗試理解.

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

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

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

  1. 構造一個節點C6, 這個節點將會有兩個上游節點: C4, C5;
  2. 將分支masterC4移動到C6.

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

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

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

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

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

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

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

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

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

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

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

輸入圖片說明

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

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

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

輸入圖片說明

沒有意外, 這根我們上面對 merge 行為的描述是一樣的: 構造一個新的 commit 節點C, 其上游節點分別為BY, 然后將master分支標簽指向C. 相較于上面的場景, 這種情況下構造C是一定不會產生沖突的. 為什么?

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

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

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

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

那么Y->C呢? 若想讓我們的期望(1)和事實(2)都成立, Y->C必須是是一個空補丁, 也就是說, CY的快照狀態是完全一致的, 可以用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 項目的分支圖, 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 或許是一個更好的選擇:smile:.

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

現實與理想的差距

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

輸入圖片說明

向理想靠攏

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

輸入圖片說明

不過這里有個問題, 前文提過, 分支(branch)是一個可以向前滑動的標簽, 從YY'似乎不能直接前進, 我們的分支標記怎么才能轉移到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;
  6. 若第5步通過, 提交被合并, master 前進; 否則回到第2步;
  7. 已被合入的開發分支生命周期結束, 被刪除.

關于第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 我的代碼, 看到如此低級的錯誤, 似乎不太好:fearful:. 這里最好的做法就是篡改 Git 提交歷史, 把 fix 類型的 commit 與上一個 commit 合并.

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

輸入圖片說明

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

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

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

輸入圖片說明

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

輸入圖片說明

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

輸入圖片說明

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

輸入圖片說明

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

輸入圖片說明

然后執行git push -f:

輸入圖片說明

之前的 origin/feature-order-pancheng 分支所處的點從圖上消失了, 我們還有可能找回他嗎? 哦對了, 分支名只是個標簽而已, 我還記得那個點之前的sha1ccce49d, 執行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 觸發條件根據正則表達式匹配branchtag, 自動觸發項目的編譯, 打包, 測試, 部署等行為.

在分支管理中, 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 了

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 229,836評論 6 540
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 99,275評論 3 428
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 177,904評論 0 383
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,633評論 1 317
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 72,368評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,736評論 1 328
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,740評論 3 446
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,919評論 0 289
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,481評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 41,235評論 3 358
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,427評論 1 374
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,968評論 5 363
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,656評論 3 348
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 35,055評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,348評論 1 294
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 52,160評論 3 398
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,380評論 2 379

推薦閱讀更多精彩內容