Git 分支管理實(shí)戰(zhàn)

從手忙腳亂開始

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

當(dāng)我初學(xué) Git 時(shí), 我關(guān)注 Git 的工程實(shí)踐勝過其內(nèi)在的設(shè)計(jì)理念, 以至于迫切的去尋找一些所謂的最佳實(shí)踐, 然后僵硬地模仿甚至生搬硬套, 結(jié)果顯而易見, 我始終無法做到

flow,原意是水流,比喻項(xiàng)目像水流那樣修噪,順暢、自然地向前流動(dòng)鼎姊,不會(huì)發(fā)生沖擊、對撞相赁、甚至漩渦.

Git Flow

理想是行云流水, 現(xiàn)實(shí)卻往往慘不忍睹

輸入圖片說明

靜下心來想一想

收起急功近利的心態(tài), 我開始思考, Git 的設(shè)計(jì)理念到底是什么.

Git 是一種版本控制系統(tǒng), 先不談 Git 是如何設(shè)計(jì)的, 如果讓我來設(shè)計(jì)一個(gè)版本管理系統(tǒng), 該如何下手?

設(shè)計(jì)一個(gè)最簡單的版本控制系統(tǒng)

版本控制 - 畢業(yè)論文

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

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

如果場景復(fù)雜一點(diǎn)兒呢?

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

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

哈! 這不就是 git merge 嘛!

git merge 事故現(xiàn)場

不好意思, 圖放錯(cuò)了

輸入圖片說明

關(guān)注點(diǎn)到底在哪里?

導(dǎo)師的加入使得我們簡易的畢業(yè)論文版本控制系統(tǒng)變得有點(diǎn)兒力不從心, 我們必須小心處理導(dǎo)師的修改我的修改論文本身造成的影響, 如果又來一個(gè)熱心學(xué)長同時(shí)對我的論文加以指導(dǎo)(修改), 問題似乎變得更加復(fù)雜了

不知不覺中, 我們的關(guān)注點(diǎn)已經(jīng)從論文本身轉(zhuǎn)向了修改, 多人同時(shí)進(jìn)行修改使得我必須小心處理每個(gè)人的修改, 不能遺漏, 不能沖突, 也不能邏輯矛盾, 這簡直太混亂了:astonished:

Git 的設(shè)計(jì)理念

理解 Commit

通過對上面例子的分析, 相信你已經(jīng)體會(huì), 論文版本控制系統(tǒng)的核心關(guān)注點(diǎn)應(yīng)該是修改, 而不僅僅是論文本身.

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

在上圖中, B點(diǎn)應(yīng)有兩種含義:

  1. 表示一個(gè)快照, 即項(xiàng)目工程所有文件在這一刻的狀態(tài);
  2. 表示一個(gè)差異, 即B狀態(tài)與A狀態(tài)文件的差異, 亦稱作補(bǔ)丁(patch).

類比于畢業(yè)論文, 快照也就是畢業(yè)論文最終版1.doc論文本身, 差異也就是修改.

如何體現(xiàn)B點(diǎn)的這兩個(gè)屬性? (我們用[B]來表示B點(diǎn)的sha1值)

  • 回到B點(diǎn)的快照: git checkout [B]
  • 查看B點(diǎn)與上一個(gè)提交點(diǎn)的差異: git show [B]

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

git show

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

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

這時(shí)就只能借助 Git 的另一個(gè)命令git diff [X] [Y]來顯式聲明要比較任意兩個(gè)節(jié)點(diǎn)XY的差異.

磚頭有了, 城堡在哪呢?

日常一天

在一些項(xiàng)目組里, 你可能會(huì)被告誡道: "記得每天下班前提交下你的代碼." 也許他們已經(jīng)發(fā)現(xiàn): "怎么代碼又沖突了", "我寫的代碼怎么被覆蓋了", 會(huì)對你多提一句告誡: "記得提交前先拉一下代碼, 別把同事寫的覆蓋了". 于是, Git 就僅僅成為了一個(gè)遠(yuǎn)程代碼倉庫.

產(chǎn)品: "上次提的3個(gè)需求, 今天就上1個(gè), 另外2個(gè)不用了"
開發(fā): "我代碼昨天都寫完提交了, 那只能把2個(gè)需求代碼刪掉了. 我可是有代碼潔癖的, 不能讓我的項(xiàng)目里這么多無用代碼留著"

[兩小時(shí)后]
產(chǎn)品: "我想了一下, B功能還是要上的, 一共上2個(gè)功能"
開發(fā): "行吧, 我再把代碼拷貝回來"

[臨上線]
產(chǎn)品: "不行, 下掉B功能, 上C功能! 快!!!"
開發(fā): "W-- 我佛慈悲!"

[上線后]
老大: "C功能有bug, 立刻回滾"
開發(fā): "好, 我退回到上次發(fā)版的快照"

這是日常的一天, 也是糟糕的一天, 大把的時(shí)間浪費(fèi)在代碼的刪除和拷貝上, 而不是在創(chuàng)造上.

問題出在哪了?

上節(jié)我們提到, Git 每個(gè) Commit 都有兩種屬性, 快照補(bǔ)丁. 在上面的使用場景中, Git 只發(fā)揮出了不到一層功力, 大家關(guān)注的僅僅是最新提交點(diǎn)的快照, 當(dāng)然, 這個(gè)快照是極為重要的, 重要到我們的HEAD指針幾乎總是在指向他, 重要到我們會(huì)把他稱為最新的master分支.

我們把關(guān)注點(diǎn)轉(zhuǎn)移到 Git 的補(bǔ)丁屬性上來, 你每天提交的 commit 代表著你這一天的工作成果, 那么描述怎么寫?

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

或許后者稍微好一點(diǎn)兒, 至少在幾天時(shí)候查看 Git 提交記錄時(shí)能一目了然的知道這次提交包含什么修改.

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

理想情況下, A, B, C是三個(gè)獨(dú)立的功能, 分別作為三次 commit.

輸入圖片說明

更好的做法是什么?

還記得嗎? A, B, C都是獨(dú)立的補(bǔ)丁(patch), 那么A, B, C的次序是沒有關(guān)系的, 也就是說C1, B2, A3的代碼快照應(yīng)該是一樣的. 不信試一下, 可以用git diff驗(yàn)證結(jié)果.

輸入圖片說明

當(dāng)要求撤掉 B 功能時(shí), 如果可以直接刪掉 B 這次提交, 那么瞬間就達(dá)到目的了. 但是, 有兩點(diǎn)是需要考慮的:

  1. 一般來說, 大家同時(shí)使用的分支只前進(jìn), 不后退, 即不能篡改歷史;
  2. 若真的篡改了歷史, 那么 B 功能的代碼就從提交記錄上消失了, 萬一需要再次添加 B 功能, 這將是悲劇.

我們可以換一種思路來達(dá)到相同的目的: 構(gòu)造一個(gè)補(bǔ)丁, 該補(bǔ)丁B完全相反, 即把B增加的行刪除, 新增B刪除的行. 當(dāng)然, 這一切都是自動(dòng)的, 只要使用git revert [B]命令, 即可創(chuàng)建一個(gè)B的反向提交. 顯然, -B1C2的快照狀態(tài)是一致的, 可以用git diff命令驗(yàn)證.

輸入圖片說明

當(dāng)要求把 B 功能加回來時(shí), 是該祭出神器了嗎?

程序員專用鍵盤

當(dāng)然不是, 我們可以再制作一個(gè)-B的反向補(bǔ)丁--B, 負(fù)負(fù)得正嘛:laughing:. 不過這看起來怪怪的, 如果能復(fù)制一份B補(bǔ)丁重新打上就好了, git cherry-pick [B]正是我們要找的答案. 顯然, --B1, B2, C3的快照狀態(tài)必然是一致的.

輸入圖片說明

注意: 通過git cherry-pick復(fù)制的B和原有的B有不一樣的sha1, 即便這兩個(gè) commit 的內(nèi)容相同.

既然這三種狀態(tài)是等價(jià)的, 那么作為傾向于完美主義的我們, 更希望在 Git 提交歷史上留下的是最后一種干凈的狀態(tài). 但我已經(jīng)在B2狀態(tài)了, 怎么才能實(shí)現(xiàn)C3? 相信你已經(jīng)想到了辦法, 回到最初的檢出點(diǎn), 通過cherry-pick拾取A, B, C3個(gè)補(bǔ)丁, 即可創(chuàng)建一個(gè)干凈的提交歷史. 或許你還聽說過git rebase, 這是一個(gè)非常強(qiáng)大的命令, 我們會(huì)在后文討論.

重新認(rèn)識分支

當(dāng)提出A, B, C三個(gè)需求的時(shí)候, 如果分派給三個(gè)人, 每個(gè)人負(fù)責(zé)一個(gè)功能, 同時(shí)基于最新的代碼開發(fā), 那么將會(huì)進(jìn)入這種狀態(tài)

輸入圖片說明

但是, 如果我們遵循master, develop分支模型開發(fā), 那么永遠(yuǎn)不會(huì)在 Git 分支圖上看到這種狀態(tài).

我們終于討論到分支了, 或許你已經(jīng)發(fā)現(xiàn), 大家在談?wù)?Git 的時(shí)候, 分支似乎是最重要的事情, 幾乎三句不離分支; 而我們說了這么多, 還沒有提及分支這件事; 上面所有的插圖中, 盡管我把他稱作分支圖, 卻沒有分支標(biāo)記, 這并不影響我們對 Git 的理解.

再次重申一下, 我們的關(guān)注點(diǎn)是commit, 用唯一的sha1標(biāo)識, 他有兩種含義快照補(bǔ)丁.

但是, sha1不是一個(gè)好記的標(biāo)識, 我們需要給一些重要的commit別名. 前面我們已經(jīng)提到了HEAD指針, 他指向當(dāng)前的commit, 這就是一個(gè)標(biāo)識. 除此之外, Git 還有兩種重要的標(biāo)識, 分支(branch)標(biāo)簽(tag).

分支標(biāo)簽是某個(gè)commit的別名, 因此, 在 Git 命令中可以使用分支標(biāo)簽來代替commitsha1值. 比如切換到某個(gè)分支, git checkout [branch-name], 其實(shí)就是切換到了這個(gè)commit點(diǎn)的快照.

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

分支標(biāo)簽都可以作為任何一個(gè)commit的標(biāo)識, 他們區(qū)別在于:

  • 分支(branch)具有前進(jìn)功能, 可以前進(jìn)到下游commit節(jié)點(diǎn)上;

  • 標(biāo)簽(tag)僅僅綁定在一個(gè)commit, 主要應(yīng)用場景是作為版本發(fā)布的標(biāo)識.

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

  • 當(dāng)執(zhí)行git commit后, 分支就前進(jìn)了;
輸入圖片說明
輸入圖片說明
  • 執(zhí)行git merge后, 分支會(huì)前進(jìn).

當(dāng) Git 關(guān)聯(lián)到遠(yuǎn)程倉庫時(shí), 每個(gè)分支可以設(shè)置一個(gè)遠(yuǎn)程追蹤分支git branch --set-upstream-to=[origin]/[branch], 當(dāng)執(zhí)行git fetch, git pull, git push時(shí), 默認(rèn)都是在操作關(guān)聯(lián)的遠(yuǎn)程分支. 一個(gè)本地 Git 倉庫可以關(guān)聯(lián)多個(gè)遠(yuǎn)程倉庫, 習(xí)慣上默認(rèn)倉庫或者主倉庫叫做origin.

輸入圖片說明

當(dāng)本地master分支落后遠(yuǎn)程origin/master分支時(shí), 一般會(huì)執(zhí)行git pull命令跟進(jìn), 但這后面到底發(fā)生了什么?

git pull命令其實(shí)是個(gè)git fetchgit merge的組合命令, git fetch是僅僅拉取遠(yuǎn)程分支的進(jìn)度, 上圖這種狀態(tài), 遠(yuǎn)程origin/master超前了本地master, 必然是執(zhí)行了git fetch后才能看到, 一般支持 Git 的圖形工具或者 IDE 會(huì)在后臺定期做這項(xiàng)工作, 在遠(yuǎn)程分支更新后及時(shí)通知.

當(dāng)HEAD指針master時(shí), 執(zhí)行git merge origin/master, master即會(huì)前進(jìn)到origin/master.

Merge 不是合并分支嗎? 怎么變成了分支前進(jìn)?

危險(xiǎn)的 Merge

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

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

輸入圖片說明

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

輸入圖片說明

也許你發(fā)現(xiàn)了, 這里分支圖風(fēng)格變化了, 不僅僅是畫風(fēng)的轉(zhuǎn)變, 最重要的是箭頭方向. 這兩張圖是我從 Git 官方文檔復(fù)制過來的, 所以請不要質(zhì)疑他的權(quán)威. 那么是我之前的箭頭方向畫錯(cuò)了嗎?

有句話怎么說來著? 權(quán)威就是用來質(zhì)疑的! 不過質(zhì)疑之前, 我們先嘗試?yán)斫?

  • 當(dāng)箭頭由上游節(jié)點(diǎn)指向下游節(jié)點(diǎn), 就像我最初的插圖那樣. 從整個(gè)分支圖上, 我們能看到因?yàn)閳F(tuán)隊(duì)的努力, 分支正在前進(jìn), 項(xiàng)目正在進(jìn)展. 也就是說, 更符合宏觀上的趨勢;

  • 當(dāng)箭頭由下游節(jié)點(diǎn)指向上游節(jié)點(diǎn), 就像官方文檔的插圖那樣. 還記得每個(gè)commit的含義嗎? 快照差異, 是該節(jié)點(diǎn)與其上游節(jié)點(diǎn)的差異, 所以在 Git 內(nèi)部存儲時(shí), 每個(gè)commit一定會(huì)保留一個(gè)指針, 指向其上游節(jié)點(diǎn). 也就是說, 這樣的設(shè)計(jì)更能體現(xiàn) Git 的內(nèi)部設(shè)計(jì).

好了, 我們該關(guān)注 Merge 到底做了什么:

  1. 構(gòu)造一個(gè)節(jié)點(diǎn)C6, 這個(gè)節(jié)點(diǎn)將會(huì)有兩個(gè)上游節(jié)點(diǎn): C4, C5;
  2. 將分支masterC4移動(dòng)到C6.

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

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

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

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

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

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

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

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

計(jì)算機(jī)工程中最不可靠的部分是人件. 再細(xì)致的人也有犯錯(cuò)的時(shí)候, 并且相比于計(jì)算機(jī)來說, 這個(gè)概率要遠(yuǎn)遠(yuǎn)高的多, 因此還應(yīng)該引入自動(dòng)化測試機(jī)制. 比如持續(xù)集成(CI), 每當(dāng)一次合并結(jié)束后, 自動(dòng)觸發(fā)編譯和測試, 并發(fā)送測試報(bào)告.

總是把這些風(fēng)險(xiǎn)推給有經(jīng)驗(yàn)的人, 這是不公平的. 況且, 作為經(jīng)驗(yàn)欠缺的我們, 沒有機(jī)會(huì)處理風(fēng)險(xiǎn), 我們怎么積累經(jīng)驗(yàn)?zāi)? 最重要的是, 我們能做的僅僅是事后補(bǔ)救嗎? 能不能從根源上避免這種風(fēng)險(xiǎn)?

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

輸入圖片說明

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

bugfix分支從master檢出, 很幸運(yùn), master分支還沒有更新. 這時(shí), 將bugfix合入master.

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

輸入圖片說明

沒有意外, 這根我們上面對 merge 行為的描述是一樣的: 構(gòu)造一個(gè)新的 commit 節(jié)點(diǎn)C, 其上游節(jié)點(diǎn)分別為BY, 然后將master分支標(biāo)簽指向C. 相較于上面的場景, 這種情況下構(gòu)造C是一定不會(huì)產(chǎn)生沖突的. 為什么?

我們從 commit 的補(bǔ)丁屬性入手, 把B->C看成一個(gè)補(bǔ)丁, 那么我們對 merge 動(dòng)作的期望結(jié)果應(yīng)該是B->XX->Y兩個(gè)補(bǔ)丁累計(jì)作用. 也就是說:

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

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

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

那么Y->C呢? 若想讓我們的期望(1)和事實(shí)(2)都成立, Y->C必須是是一個(gè)空補(bǔ)丁, 也就是說, CY的快照狀態(tài)是完全一致的, 可以用git diff [C] [Y]驗(yàn)證一下我們的推論.

為什么要有這個(gè)空補(bǔ)丁, 直接將使用Y節(jié)點(diǎn)不行嗎? 當(dāng)然可以!

觀察一下我們的 merge 命令, 有一個(gè)附加參數(shù)--no-ff, 這個(gè)參數(shù)強(qiáng)制關(guān)掉了 fast-forward 特性. 如果我們不添加這個(gè)參數(shù), 直接只用git merge bugfix, 那么得到的結(jié)果將是這樣的:

輸入圖片說明

master直接被指向了Y節(jié)點(diǎn). 還記得嗎, 讓分支前進(jìn)的第二種方法是什么來著? git merge, 這不是就例子嘛!

執(zhí)行git merge [X]動(dòng)作時(shí), 若無需構(gòu)造新的 commit 節(jié)點(diǎn), 直接將當(dāng)前分支標(biāo)簽前進(jìn)到要X節(jié)點(diǎn), 這就是所謂的 fast-forward 特性.

這種情況下的 merge 動(dòng)作讓風(fēng)險(xiǎn)大大降低. 首先 commit X, Y的提交者要對兩次修改負(fù)責(zé), 他們有責(zé)任保證每次提交后的代碼是可以通過編譯和測試的; 其次, 項(xiàng)目負(fù)責(zé)人在將bugfix分支合入master之前, 只需確保Y的快照版本是正確的, 因?yàn)?merge 動(dòng)作將不會(huì)帶來任何再次的變更, 只是將分支前進(jìn)到Y的快照, 這大大降低了 merge 的風(fēng)險(xiǎn).

再次提醒, 慎用git pull, 這條命令隱含了git fetch, git merge兩條命令. 一個(gè)更好的做法是先git fetch獲取遠(yuǎn)程分支狀態(tài), 當(dāng)你確認(rèn)本地關(guān)聯(lián)的分支能與遠(yuǎn)程分支以fast-forward合并的時(shí)候, 再執(zhí)行git merge或者git pull.

建筑理想的城堡

理想的分支圖

我們已經(jīng)找到了一種來盡量避免 merge 風(fēng)險(xiǎn)的場景, 在這種場景下, 我們會(huì)構(gòu)造出怎樣的分支圖?

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

輸入圖片說明

(該圖是 RedHat 旗下 debezium 項(xiàng)目的分支圖, Github 傳送門

如果我們使用git merge --no-ff參數(shù), 結(jié)果將是這樣的:

輸入圖片說明

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

看到區(qū)別了嗎? fast-forward 結(jié)果將會(huì)是一條一線, 這是最干凈整潔的分支圖, 但是相應(yīng)的, 我們已經(jīng)無法一目了然的區(qū)分出哪幾個(gè) commit 構(gòu)成一個(gè)功能, 必須通過規(guī)范的注釋(比如上圖中全部以 JIRA 編號開頭)來做分區(qū); 而--no-ff參數(shù)雖然讓分支圖變得看上去復(fù)雜了一點(diǎn)兒, 但卻非常直觀地保留了 commit 集合和功能的對應(yīng)關(guān)系.

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

對于超大規(guī)模的開源項(xiàng)目來講, 每一個(gè) commit 都不是隨意的, 必須要有 JIRA, 郵件列表, Github Issue 列表等諸如此類的討論, 明確 commit 的功能和影響, 確保每個(gè) Commit 只做一件事, 變動(dòng)最小化, 然后通過 Pull Request 方式請求合并至主倉庫的主線分支. 在這種情況下, 使用--no-ff的話, 幾乎每個(gè) commit 都會(huì)產(chǎn)生一個(gè)空的 merge 節(jié)點(diǎn), 分支圖就變成了鋸齒狀, 帶來的收益微乎其微; 而規(guī)范 commit 注釋, 并且使用 fast-forward 或許是一個(gè)更好的選擇:smile:.

對于需要快速響應(yīng)變化的互聯(lián)網(wǎng)公司來說, 每一次改動(dòng)之前都先建立 JIRA 或者 Issue, 這幾乎不太現(xiàn)實(shí), 通過--no-ff的節(jié)點(diǎn)加上相對簡潔明了的注釋可能是一個(gè)更明智的選擇.

現(xiàn)實(shí)與理想的差距

但多數(shù)情況下, 現(xiàn)實(shí)場景并不滿足這樣的狀態(tài), 因?yàn)轫?xiàng)目不是一個(gè)人在開發(fā), 在我們提交的同時(shí), 別人也在提交, 當(dāng)我們的分支準(zhǔn)備合入master時(shí), master已經(jīng)前進(jìn)了, 又回到了最初那種糟糕的狀態(tài). 是去面對糟糕的狀態(tài), 還是避免糟糕的狀態(tài), 想辦法修正它?

輸入圖片說明

向理想靠攏

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

輸入圖片說明

不過這里有個(gè)問題, 前文提過, 分支(branch)是一個(gè)可以向前滑動(dòng)的標(biāo)簽, 從YY'似乎不能直接前進(jìn), 我們的分支標(biāo)記怎么才能轉(zhuǎn)移到Y'上呢?

一個(gè)粗暴的方法是, 我們可以先刪掉bugfix分支, 然后從Y'創(chuàng)建它. 不過, Git 也提供了將分支標(biāo)簽指向任意commit節(jié)點(diǎn)的命令, 即git reset.

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

為了達(dá)到這種理想的分支狀態(tài), 我們要經(jīng)常這么干, 這一切工作似乎變得有點(diǎn)兒繁瑣, 要執(zhí)行這么多步驟才能達(dá)到分支嫁接的目的. 對的, Git 為我們提供了自動(dòng)化方案, 那就是強(qiáng)大的 rebase.

Rebase 譯作變基, 從字面上理解, rebase 命令可以改變當(dāng)前分支的基點(diǎn), 我們現(xiàn)在僅關(guān)注 rebase 功能其中的一個(gè)特性, 來達(dá)到我們分支嫁接的目的就足夠了. 回到最初的場景, bugfix 分支還指向Y, 這是我們只要執(zhí)行git rebase master, 即可達(dá)到目的.

我們本地的bugfix已經(jīng)變基完成, 若它已經(jīng)關(guān)聯(lián)過遠(yuǎn)程分支, 那么origin/bugfix還處在Y, 我們要把本地的狀態(tài)變更推送到遠(yuǎn)程, 如果接著執(zhí)行git push, 將會(huì)報(bào)錯(cuò):

輸入圖片說明

可以看到, Git 服務(wù)器拒絕了我們的推送請求, 并返回了一些提示信息, 或許看到這場面, 你一下就慌了, 我辛苦寫的的代碼不會(huì)丟掉吧! 提示里面有git pull命令, 我是不是應(yīng)該執(zhí)行, 挽救一下!

當(dāng)真正執(zhí)行了git pull命令后, 這才是糟糕的場面!

別忘了, git pull暗含git merge語義, 這會(huì)導(dǎo)致一次合并, 構(gòu)造的一個(gè)新的 commit Z, 上游分別是 bugfix Y'和 origin/bugfix Y, bugfix 指向了Z. 如果這時(shí)再執(zhí)行了git push命令, 那么這糟糕的分支圖就推到了服務(wù)器上, 整個(gè)團(tuán)隊(duì)將會(huì)看到你把分支圖搞亂了, 這畫面簡直不可描述! (如果你腦補(bǔ)不出來這時(shí)分支圖的樣子, 下個(gè)實(shí)操案例中會(huì)演示)

輸入圖片說明

記住, 不要慌, 你已經(jīng)了解了 Git 的原理, 你有能力掌控 Git, 而不是被一兩個(gè)莫名的錯(cuò)誤嚇退了. 還記得剛剛使用的git reset命令嗎? 他可以把分支強(qiáng)制指向任一commit, 我們使用git reset --hard [Y'] 不就回到剛才的狀態(tài)了嗎?

好了, 假裝剛才什么都沒發(fā)生, 我們仔細(xì)看看服務(wù)器返回的錯(cuò)誤, 并且思考一下問題到底出在哪里?

首先, git push到底在做什么? pull 和 push 是一對反義詞, git pull是把遠(yuǎn)程分支進(jìn)度同步到本地, 然后嘗試將遠(yuǎn)程分支合并到關(guān)聯(lián)的本地分支; git push在做類似的事情, 不過是相反的, 他會(huì)先把本地分支同步到遠(yuǎn)程, 然后嘗試將本地分支合并到關(guān)聯(lián)的遠(yuǎn)程分支. 但是, 當(dāng)無法滿足 fast-forward 條件時(shí), git push會(huì)直接報(bào)錯(cuò), 而不是嘗試構(gòu)造一個(gè)新的commit. 這就是我們剛剛遇到的錯(cuò)誤場景.

但很顯然, 我們在本地調(diào)整了分支, 并且期望把調(diào)整后的狀態(tài)推送到遠(yuǎn)程, 覆蓋遠(yuǎn)程分支原有的狀態(tài). 這時(shí)需要添加一個(gè)參數(shù)git push --force, 強(qiáng)制覆蓋遠(yuǎn)程關(guān)聯(lián)分支. 現(xiàn)在遠(yuǎn)程的 bugfix 分支和本地 bugfix 保持同步了, 都指向了Y', team leader 可以 review 代碼, 然后合入 master 了.

輸入圖片說明

對主分支保持敬畏

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

所以, 應(yīng)該制定一個(gè)約定: 公共分支不允許 force push. 也就是說, 公共分支只能前進(jìn).

在常用的 Git 服務(wù)器上, 比如碼云, GitLab, Github都支持分支保護(hù)功能, 我們至少要設(shè)定一個(gè)保護(hù)分支(以 master 為例), 作為功能分支. 該分支應(yīng)該有以下特性:

  • 只能前進(jìn), 也就是不允許 force push;

  • 不允許直接 commit, 只能通過 merge 動(dòng)作使分支前進(jìn);

  • 收緊 merge 權(quán)限, 只允許部分項(xiàng)目審查者執(zhí)行 merge;

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

  • 每次 merge 前, 必須進(jìn)行 code review 和持續(xù)集成(CI);

  • Commit 提交者, code review 者, merge 者都要對代碼變更負(fù)責(zé).

在這種模式下, 所有團(tuán)隊(duì)成員以 master 分支為核心進(jìn)行開發(fā). 每個(gè)人接到開發(fā)需求后:

  1. 從最新的遠(yuǎn)程 master 分支檢出自己的開發(fā)分支;
  2. 開發(fā);
  3. 開發(fā)結(jié)束后, 以最新的遠(yuǎn)程 master 為基點(diǎn), 執(zhí)行 rebase 操作, 解決掉沖突;
  4. 向有 merge 權(quán)限的人提交合并請求(碼云和 Github 稱作 Pull Request, Gitlab 稱作 Merge Request)
  5. Code review 和 CI;
  6. 若第5步通過, 提交被合并, master 前進(jìn); 否則回到第2步;
  7. 已被合入的開發(fā)分支生命周期結(jié)束, 被刪除.

關(guān)于第7步, 你沒看錯(cuò), 一個(gè)分支的生命周期就是這么短暫! 這取決于一個(gè)特性的大小, 可能只有幾分鐘, 或許有幾天, 而不是像 master 分支一樣永遠(yuǎn)存在.

每個(gè)人在開發(fā)過程中都應(yīng)該有自己的分支, (我推薦以你的名字結(jié)尾, 這樣便于標(biāo)識), 這條分支是你的私有分支. 你應(yīng)該對 master 分支保持敬畏, 但對于你的私有分支, 你可以任意的 force push, rebase, 甚至你不把他放到項(xiàng)目的公有倉庫, 放到自己 fork 的私有倉庫里, 這就是一張草稿紙!

讓我們篡改歷史吧!

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

輸入圖片說明

在macOS終端上通過git log --oneline --graph --all可以打印出上面的分支圖, 這是我最常用的一個(gè)命令, 在linux上的表現(xiàn)行為可能會(huì)有點(diǎn)兒區(qū)別, 或許你可以嘗試git log --oneline --graph --all --decorate=short | less -r, 或者參考git log --help進(jìn)行調(diào)整, 來達(dá)到你想要的打印效果. 當(dāng)然, 使用圖形軟件查看分支圖也是一個(gè)很好的選擇.

看, 我在開發(fā)一個(gè)訂單功能, 當(dāng)我開始開發(fā)的時(shí)候, master 在c80dc1e這個(gè)提交點(diǎn), 我通過git checkout -b feature-order-pancheng檢出一個(gè)自己的開發(fā)分支.

我在開發(fā)過程中, 做了7次 commit, 但事實(shí)上只有4個(gè)是有意義的, 其他的幾個(gè)僅僅是我在提交后立刻就發(fā)現(xiàn)了很明顯的錯(cuò)誤, 然后修正過來了, 這看起來就是個(gè)草稿, 如果同事 review 我的代碼, 看到如此低級的錯(cuò)誤, 似乎不太好:fearful:. 這里最好的做法就是篡改 Git 提交歷史, 把 fix 類型的 commit 與上一個(gè) commit 合并.

我們現(xiàn)在執(zhí)行git rebase -i c80dc1e, -i代表交互模式:

輸入圖片說明

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

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

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

輸入圖片說明

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

輸入圖片說明

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

輸入圖片說明

不出意外, Git 服務(wù)器拒絕了我們的推送請求, 因?yàn)椴粷M足 fast-forward 條件. 現(xiàn)在你應(yīng)該不會(huì)慌了吧! 我們假裝慌一把, "根據(jù)提示"執(zhí)行git pull:

輸入圖片說明

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

輸入圖片說明

然后執(zhí)行git push -f:

輸入圖片說明

之前的 origin/feature-order-pancheng 分支所處的點(diǎn)從圖上消失了, 我們還有可能找回他嗎? 哦對了, 分支名只是個(gè)標(biāo)簽而已, 我還記得那個(gè)點(diǎn)之前的sha1ccce49d, 執(zhí)行git checkout ccce49d, 分支又回來了, 原來只是隱藏了! 我們把這種沒有任何標(biāo)簽的分支稱謂游離分支, 他默認(rèn)不會(huì)在分支圖中顯示, 并且會(huì)在一段時(shí)間后由 Git 進(jìn)行垃圾回收, 才會(huì)真正的消失, 在此之前, 我們可以通過git reglog找到他們的sha1, 回到那個(gè)快照.

訂單功能開發(fā)好了, 可以向主分支提合并請求了, 哦, 對了, master 已經(jīng)前進(jìn)了, 我們提 PR 之前必須先跟進(jìn). 執(zhí)行git rebase master, git push -f, 然后再查看分支圖:

輸入圖片說明

這時(shí)就可以去提交 Pull Request 了.

輸入圖片說明
輸入圖片說明

當(dāng) PR 通過后, 你的分支將被合入 master 分支, 執(zhí)行git fetch拉取遠(yuǎn)程分支信息, 然后查看分支圖:

輸入圖片說明

嗯, 一次愉快的開發(fā)結(jié)束了.

如果大家都遵守這個(gè)約定, 那么我們的分支圖將會(huì)是這樣:

輸入圖片說明

雖然我們在 master 分支合并上使用了--no-ff方式, 但是它等價(jià)于是一條直線, 這對 code review 和協(xié)作開發(fā)將十分友好.

那么發(fā)版呢?

相比于往 master 上 merge 提交, 項(xiàng)目發(fā)版是一個(gè)更謹(jǐn)慎的話題.

我們上面已經(jīng)提到持續(xù)集成(CI), 這是一種自動(dòng)化的打包和測試機(jī)制, 往往會(huì)與持續(xù)交付(CD)一起協(xié)作. 我們可以將 Git 的某些行為作為 CI/CD 的觸發(fā)條件, 來達(dá)到自動(dòng)化打包, 測試, 部署的能力.

我們對分支做以下規(guī)范:

  • master 主功能分支;
  • feature-xxx-[developer name] 特性開發(fā)分支;
  • fix-xxx-[developer name] 非緊急bug修復(fù)分支;
  • hotfix-xxx-[developer name] 線上緊急bug修復(fù)分支;
  • dev-[date] 開發(fā)環(huán)境發(fā)布分支(或tag);
  • test-[date] 測試環(huán)境發(fā)布分支(或tag);
  • uat-[date] 準(zhǔn)生產(chǎn)環(huán)境發(fā)布分支(或tag);
  • release-[date] 線上發(fā)布分支(或tag).

在 Git 服務(wù)器中, 幾乎都會(huì)提供 CI/CD 功能, CI/CD 觸發(fā)條件根據(jù)正則表達(dá)式匹配branchtag, 自動(dòng)觸發(fā)項(xiàng)目的編譯, 打包, 測試, 部署等行為.

在分支管理中, dev-[date]分支可以由任意開發(fā)人員隨時(shí)檢出發(fā)布到開發(fā)環(huán)境聯(lián)調(diào); test-[date], uat-[date], release-[date]原則上必須從master上逐級檢出, 分別測試, 若發(fā)現(xiàn)問題, 進(jìn)行 bugfix.

輸入圖片說明

看, 我們從75a8e22檢出test-20190623分支, 當(dāng)推送到服務(wù)器上時(shí), CI/CD 會(huì)自動(dòng)觸發(fā), 最終項(xiàng)目被部署到測試服務(wù)器上. 我們在測試上發(fā)現(xiàn)一個(gè) bug, 在真正上線前發(fā)現(xiàn)的 bug 總比上線后好. bugfix 后, 我們認(rèn)為沒有問題了, 檢出release-20190623分支, 觸發(fā) CI/CD, 部署到生產(chǎn)環(huán)境. 半天后, 我們發(fā)現(xiàn)一個(gè)緊急的線上 bug, 我們緊急創(chuàng)建了 hotfix 分支, 在 CI 通過后, 將其合入到release-20190623分支, 然后刪除 hotfix 分支.

看上去這次發(fā)版成功了, 那么這兩個(gè) bugfix commit 怎么合入到 master 呢?

還記得我們說 master 分支的 merge 原則嗎? 只允許 merge 滿足 fast-forward 條件的 commit. 在我們開始測試后, master 已經(jīng)前進(jìn), bugfix commit(即在test-[date], uat-[date], release-[date]上的 hotfix) 就不能直接合并到 master, 并且發(fā)布點(diǎn) rebase 是有風(fēng)險(xiǎn)的, 這時(shí)就只能通過 cherry-pick 來把補(bǔ)丁手動(dòng)打回到 master 分支上了!

我們從最新 master 切出一個(gè) fix 分支, 并把兩個(gè)補(bǔ)丁通過 cherry-pick 移植過來:

輸入圖片說明

接下來就是 PR 流程, 當(dāng)合入 master 后, 刪除該 fix 分支:

輸入圖片說明

嗯, 這篇文章前后大約寫了一個(gè)禮拜, 是時(shí)候提 PR 了, 我要去 rebase 了

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末相寇,一起剝皮案震驚了整個(gè)濱河市,隨后出現(xiàn)的幾起案子钮科,更是在濱河造成了極大的恐慌唤衫,老刑警劉巖,帶你破解...
    沈念sama閱讀 222,104評論 6 515
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件绵脯,死亡現(xiàn)場離奇詭異佳励,居然都是意外死亡,警方通過查閱死者的電腦和手機(jī)蛆挫,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 94,816評論 3 399
  • 文/潘曉璐 我一進(jìn)店門赃承,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人璃吧,你說我怎么就攤上這事楣导。” “怎么了畜挨?”我有些...
    開封第一講書人閱讀 168,697評論 0 360
  • 文/不壞的土叔 我叫張陵筒繁,是天一觀的道長。 經(jīng)常有香客問我巴元,道長毡咏,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 59,836評論 1 298
  • 正文 為了忘掉前任逮刨,我火速辦了婚禮呕缭,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘修己。我一直安慰自己恢总,他們只是感情好,可當(dāng)我...
    茶點(diǎn)故事閱讀 68,851評論 6 397
  • 文/花漫 我一把揭開白布睬愤。 她就那樣靜靜地躺著片仿,像睡著了一般。 火紅的嫁衣襯著肌膚如雪尤辱。 梳的紋絲不亂的頭發(fā)上砂豌,一...
    開封第一講書人閱讀 52,441評論 1 310
  • 那天,我揣著相機(jī)與錄音光督,去河邊找鬼阳距。 笑死,一個(gè)胖子當(dāng)著我的面吹牛结借,可吹牛的內(nèi)容都是我干的筐摘。 我是一名探鬼主播,決...
    沈念sama閱讀 40,992評論 3 421
  • 文/蒼蘭香墨 我猛地睜開眼船老,長吁一口氣:“原來是場噩夢啊……” “哼咖熟!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起努隙,我...
    開封第一講書人閱讀 39,899評論 0 276
  • 序言:老撾萬榮一對情侶失蹤球恤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個(gè)月后荸镊,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體咽斧,經(jīng)...
    沈念sama閱讀 46,457評論 1 318
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 38,529評論 3 341
  • 正文 我和宋清朗相戀三年躬存,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了张惹。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點(diǎn)故事閱讀 40,664評論 1 352
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡岭洲,死狀恐怖宛逗,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情盾剩,我是刑警寧澤雷激,帶...
    沈念sama閱讀 36,346評論 5 350
  • 正文 年R本政府宣布替蔬,位于F島的核電站,受9級特大地震影響屎暇,放射性物質(zhì)發(fā)生泄漏承桥。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 42,025評論 3 334
  • 文/蒙蒙 一根悼、第九天 我趴在偏房一處隱蔽的房頂上張望凶异。 院中可真熱鬧,春花似錦挤巡、人聲如沸剩彬。這莊子的主人今日做“春日...
    開封第一講書人閱讀 32,511評論 0 24
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽喉恋。三九已至,卻和暖如春粪摘,著一層夾襖步出監(jiān)牢的瞬間瀑晒,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 33,611評論 1 272
  • 我被黑心中介騙來泰國打工徘意, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留苔悦,地道東北人。 一個(gè)月前我還...
    沈念sama閱讀 49,081評論 3 377
  • 正文 我出身青樓椎咧,卻偏偏與公主長得像玖详,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個(gè)殘疾皇子勤讽,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 45,675評論 2 359

推薦閱讀更多精彩內(nèi)容