據(jù)模型到分支合并,徹底解決版本控制難題)
1. 從一次“血淚史”說起為什么你總在合并時(shí)翻車我敢打賭每個(gè)用過Git的程序員都至少經(jīng)歷過一次“合并災(zāi)難”??赡苁悄阈判臐M滿地執(zhí)行了git merge結(jié)果屏幕上蹦出一堆 HEAD的沖突標(biāo)記讓你瞬間頭皮發(fā)麻也可能是你以為只是簡(jiǎn)單地git push卻收到了“非快進(jìn)式推送被拒絕”的冰冷提示更慘的是你試圖用git reset --hard回退版本結(jié)果發(fā)現(xiàn)幾天的代碼“憑空消失”欲哭無淚。這些問題表面上看是操作失誤但根子在于對(duì)Git底層核心原理的一知半解。很多人把Git當(dāng)成一個(gè)“高級(jí)的文件復(fù)制粘貼工具”或者“帶版本的文件管理器”只記住了add、commit、push、pull這幾個(gè)命令的固定組合。一旦遇到分支策略復(fù)雜、多人協(xié)作頻繁或者需要處理歷史記錄的場(chǎng)景就完全抓瞎只能靠搜索引擎的只言片語和玄學(xué)般的嘗試來解決問題往往讓情況變得更糟。今天我不打算再給你羅列一堆命令大全。那些東西網(wǎng)上一搜一大把。我想帶你穿透Git那層“命令”的外殼直接看到它的骨骼和內(nèi)臟——也就是它的底層數(shù)據(jù)模型和核心對(duì)象。當(dāng)你真正理解了Git在倉庫里到底存儲(chǔ)了什么、這些存儲(chǔ)單元之間如何連接、分支和標(biāo)簽又是什么“廉價(jià)貨”之后你會(huì)發(fā)現(xiàn)所有那些令人困惑的操作無論是合并、變基、重置還是推送拉取都變得無比清晰和自然。你不會(huì)再懼怕沖突因?yàn)槟阒罌_突產(chǎn)生的精確位置和原因你不會(huì)再搞丟代碼因?yàn)槟阒烂恳粋€(gè)提交都像磐石一樣穩(wěn)固你也能設(shè)計(jì)出更清晰、高效的團(tuán)隊(duì)協(xié)作流程。所以忘掉那些復(fù)雜的命令參數(shù)讓我們回到最初的地方看看Git到底是怎么“想”的。我保證看完這篇Git對(duì)你而言將不再是一個(gè)黑盒而是一個(gè)你可以清晰理解和掌控的工具。如果看完還不會(huì)你確實(shí)可以來找我——不過我相信那大概率是因?yàn)槟阆牒臀疑钊胩接懜M(jìn)階的話題了。2. 基石拆解Git的倉庫數(shù)據(jù)模型要理解Git的一切行為必須從它的數(shù)據(jù)模型開始。這就像學(xué)編程先理解變量和內(nèi)存學(xué)數(shù)據(jù)庫先理解表和索引。Git的倉庫.git目錄內(nèi)部主要存儲(chǔ)了四種核心對(duì)象Blob、Tree、Commit和Tag。它們通過SHA-1哈希值相互引用構(gòu)成一個(gè)有向無環(huán)圖。這個(gè)設(shè)計(jì)是Git強(qiáng)大、高效且分布式的根本。2.1 四大核心對(duì)象Git倉庫里到底存了什么Blob對(duì)象這是最基礎(chǔ)的數(shù)據(jù)單元。你可以把它想象成一個(gè)“文件內(nèi)容快照”。但注意它不存儲(chǔ)文件名只存儲(chǔ)文件內(nèi)容。無論你有一個(gè)叫hello.txt還是main.py的文件只要它們的內(nèi)容完全相同在Git倉庫里就只會(huì)存在唯一一個(gè)對(duì)應(yīng)的Blob對(duì)象。它的SHA-1哈希值就是根據(jù)其內(nèi)容計(jì)算出來的。這種設(shè)計(jì)實(shí)現(xiàn)了高效的去重存儲(chǔ)。Tree對(duì)象Tree對(duì)象解決了“文件內(nèi)容有了那目錄結(jié)構(gòu)呢”的問題。它相當(dāng)于一個(gè)目錄的快照記錄了某個(gè)時(shí)刻目錄的結(jié)構(gòu)。一個(gè)Tree對(duì)象里包含了一系列條目每個(gè)條目指向一個(gè)Blob對(duì)象代表文件或者另一個(gè)Tree對(duì)象代表子目錄并且記錄了對(duì)應(yīng)的文件名或目錄名。所以是Tree對(duì)象將Blob對(duì)象組織成了我們熟悉的文件樹結(jié)構(gòu)。Commit對(duì)象這是版本控制的核心。一個(gè)Commit對(duì)象代表一次完整的提交它包含了以下關(guān)鍵信息指向一個(gè)Tree對(duì)象這個(gè)Tree對(duì)象就是本次提交時(shí)整個(gè)項(xiàng)目工作目錄的根目錄快照。通過它可以還原出提交那一刻的所有文件內(nèi)容。指向父提交指向一個(gè)或多個(gè)之前的Commit對(duì)象。這形成了提交歷史鏈。第一次提交沒有父提交合并提交則有兩個(gè)或更多父提交。作者和提交者信息姓名、郵箱、時(shí)間戳。提交信息就是你git commit -m “...”時(shí)寫的內(nèi)容。Commit對(duì)象本身也由這些信息計(jì)算出一個(gè)唯一的SHA-1哈希值我們常說的“版本號(hào)”或“commit id”就是指這個(gè)哈希值。正是這個(gè)不可變的哈希值保證了Git歷史的完整性。任何對(duì)歷史的篡改都會(huì)導(dǎo)致后續(xù)所有提交的哈希值改變從而立刻暴露。Tag對(duì)象這是一個(gè)可選的、更“重”的標(biāo)簽對(duì)象通常用于標(biāo)記重要的里程碑如v1.0.0。它指向一個(gè)特定的Commit對(duì)象并且可以包含標(biāo)簽名、標(biāo)簽信息、簽名等。我們更常用的git tag v1.0創(chuàng)建的是“輕量標(biāo)簽”它其實(shí)只是一個(gè)直接指向Commit的引用不創(chuàng)建Tag對(duì)象。注意這里有一個(gè)關(guān)鍵理解點(diǎn)。Git保存的不是文件的“差異”而是每次提交的完整“快照”。當(dāng)你提交時(shí)Git會(huì)為所有有變動(dòng)的文件創(chuàng)建新的Blob并為包含它們的目錄創(chuàng)建新的Tree最終形成一個(gè)新的Commit。雖然存儲(chǔ)的是快照但Git在顯示歷史差異git diff或傳輸數(shù)據(jù)時(shí)會(huì)智能地計(jì)算和壓縮差異所以實(shí)際效率很高。2.2 引用與符號(hào)引用分支和HEAD的本質(zhì)理解了核心對(duì)象我們?cè)賮砜纯疵刻齑蚪坏赖摹胺种А钡降资鞘裁础T贕it里分支本質(zhì)上就是一個(gè)指向某個(gè)Commit對(duì)象的、可移動(dòng)的指針。.git/refs/heads/目錄下每個(gè)文件就是一個(gè)分支。比如master分支其實(shí)就是.git/refs/heads/master這個(gè)文件里面簡(jiǎn)單存儲(chǔ)了一個(gè)Commit對(duì)象的哈希值例如a1b2c3d...。這個(gè)哈希值指向哪個(gè)提交哪個(gè)提交就是master分支的“頭”。HEAD則是一個(gè)特殊的符號(hào)引用它通常指向你當(dāng)前所在的分支。.git/HEAD文件里的內(nèi)容可能是ref: refs/heads/feature/login。這意味著你當(dāng)前在feature/login分支上。當(dāng)你做出新的提交時(shí)Git會(huì)根據(jù)工作區(qū)變更創(chuàng)建新的Blob和Tree對(duì)象。創(chuàng)建一個(gè)新的Commit對(duì)象其父提交就是當(dāng)前HEAD所指向的提交即feature/login指針指向的那個(gè)提交。將feature/login這個(gè)分支指針即.git/refs/heads/feature/login文件里的哈希值更新為這個(gè)新創(chuàng)建的Commit。HEAD因?yàn)橹赶騠eature/login所以自動(dòng)“跟”著前進(jìn)了。這個(gè)過程完美解釋了為什么創(chuàng)建分支如此廉價(jià)——僅僅是在.git/refs/heads/下創(chuàng)建一個(gè)包含某個(gè)提交哈希的新文件而已。切換分支就是改變.git/HEAD文件的內(nèi)容并據(jù)此更新工作區(qū)文件。2.3 圖解一次提交的誕生讓我們把上述過程串聯(lián)起來假設(shè)我們有一個(gè)簡(jiǎn)單的項(xiàng)目首次提交一個(gè)README.md文件。工作區(qū)你創(chuàng)建了README.md內(nèi)容為# My Project。暫存區(qū)執(zhí)行g(shù)it add README.md。Git會(huì)計(jì)算# My Project內(nèi)容的SHA-1哈希值假設(shè)為blob_sha1。將內(nèi)容壓縮后以Blob對(duì)象的形式存入.git/objects/目錄。創(chuàng)建Tree執(zhí)行g(shù)it commit -m “init”。Git會(huì)為當(dāng)前暫存區(qū)此時(shí)只有README.md創(chuàng)建一個(gè)Tree對(duì)象。這個(gè)Tree對(duì)象包含一個(gè)條目(mode: 100644, type: blob, sha1: blob_sha1, name: “README.md”)。這個(gè)Tree對(duì)象也會(huì)被計(jì)算哈希假設(shè)為tree_sha1并存入對(duì)象庫。創(chuàng)建CommitGit接著創(chuàng)建Commit對(duì)象包含指向的Tree:tree_sha1父提交: 無因?yàn)槭鞘状翁峤蛔髡?提交者信息提交信息: “init” 計(jì)算這個(gè)Commit對(duì)象的哈希值假設(shè)為commit_A并存入對(duì)象庫。更新分支指針Git將當(dāng)前分支比如master的指針文件.git/refs/heads/master的內(nèi)容更新為commit_A。至此一次完整的提交就完成了。整個(gè)倉庫的數(shù)據(jù)關(guān)系可以簡(jiǎn)化為master分支指針 - Commit A - Tree A - Blob for README.md。下一次提交新的Commit B會(huì)指向Commit A作為父提交并指向一個(gè)新的Tree B可能包含了變化的文件內(nèi)容然后master指針再移動(dòng)到Commit B。這個(gè)鏈條就是你的提交歷史。3. 分支合并的三種策略與底層抉擇理解了數(shù)據(jù)模型我們終于可以深入最令人頭疼的環(huán)節(jié)合并。合并的本質(zhì)是將兩個(gè)或多個(gè)分支的歷史聯(lián)系在一起。Git提供了幾種主要的合并策略每種策略在底層做的事情截然不同產(chǎn)生的歷史圖也不同。3.1 快進(jìn)合并當(dāng)歷史是線性的時(shí)候這是最簡(jiǎn)單的情況。假設(shè)你從master分支的C1提交創(chuàng)建了一個(gè)feature分支然后在feature上進(jìn)行了C2和C3兩次提交。在此期間master分支沒有任何新的提交。此時(shí)的分支狀態(tài)圖是線性的C1 (master) \ C2 — C3 (feature)當(dāng)你切回master分支并執(zhí)行g(shù)it merge feature時(shí)Git發(fā)現(xiàn)master指向的C1直接就是feature分支的祖先。這意味著feature的所有新提交C2, C3都是在master的基礎(chǔ)上進(jìn)行的沒有分叉。底層操作Git根本不會(huì)創(chuàng)建新的合并提交。它只是簡(jiǎn)單地將master分支的指針快速向前移動(dòng)直接指向feature分支當(dāng)前的提交C3。這就叫“快進(jìn)”。合并后的歷史仍然是一條直線C1 — C2 — C3 (master, feature)feature分支指針通常就完成了使命可以被刪除了。實(shí)操心得快進(jìn)合并很干凈但有時(shí)我們可能希望保留分支存在的痕跡。這時(shí)可以使用git merge --no-ff feature來禁止快進(jìn)強(qiáng)制創(chuàng)建一個(gè)新的合并提交。這樣在歷史圖中就能清晰地看到一個(gè)合并節(jié)點(diǎn)表明這里曾有一個(gè)特性分支被集成。在團(tuán)隊(duì)開發(fā)中對(duì)長(zhǎng)期存在的特性分支使用--no-ff是個(gè)好習(xí)慣能讓歷史更清晰。3.2 三方合并處理分叉的歷史更常見的情況是在你開發(fā)feature分支的同時(shí)master分支也有其他人提交了新的修改C4。C2 — C3 (feature) / C1 — C4 (master)現(xiàn)在master和feature從C1開始分道揚(yáng)鑣了。此時(shí)在master上執(zhí)行g(shù)it merge feature快進(jìn)合并不再適用。Git會(huì)啟動(dòng)默認(rèn)的“遞歸三方合并”策略。底層操作尋找共同祖先Git首先找到master(C4) 和feature(C3) 的“最近共同祖先”在這個(gè)例子里就是C1。這個(gè)祖先提交作為合并的“基準(zhǔn)”。計(jì)算差異Git會(huì)分別計(jì)算差異A從祖先C1到master當(dāng)前C4的變化即別人在master上改了啥。差異B從祖先C1到feature當(dāng)前C3的變化即你在feature分支上改了啥。應(yīng)用合并Git嘗試將這兩組差異同時(shí)應(yīng)用到共同祖先C1的狀態(tài)上。如果兩組差異修改了不同的文件或者修改了同一文件的不同部分Git可以自動(dòng)合并生成一個(gè)新的合并結(jié)果。創(chuàng)建合并提交自動(dòng)合并成功后或手動(dòng)解決沖突后Git會(huì)創(chuàng)建一個(gè)新的提交即合并提交C5。這個(gè)提交的特殊之處在于它有兩個(gè)父提交C4和C3。C2 — C3 (feature) / \ C1 — C4 ———— C5 (master)合并提交C5指向一個(gè)新的Tree對(duì)象這個(gè)Tree反映了合并后的最終文件狀態(tài)。master分支指針移動(dòng)到C5。3.3 變基重寫歷史追求線性合并會(huì)產(chǎn)生分叉的歷史有些人喜歡這種能體現(xiàn)真實(shí)開發(fā)過程的歷史圖但也有些人追求簡(jiǎn)潔的線性歷史。git rebase就是后者的選擇。繼續(xù)用上面的例子你在feature分支上歷史是C1 — C2 — C3。master已經(jīng)前進(jìn)到了C1 — C4。底層操作當(dāng)你在feature分支上執(zhí)行g(shù)it rebase master時(shí)Git會(huì)做以下事情找到待重演的提交找到當(dāng)前分支 (feature) 相對(duì)于目標(biāo)分支 (master) 的最近共同祖先之后的所有提交。這里是C2和C3。暫存這些提交的差異Git會(huì)依次提取C2和C3相對(duì)于其各自父提交所引入的變更即差異補(bǔ)丁。重置分支基底將feature分支的指針重置到master的最新提交C4上。注意此時(shí)C2和C3在feature分支上暫時(shí)“消失”了。重新應(yīng)用提交Git在C4這個(gè)新的基礎(chǔ)上依次重新應(yīng)用之前暫存的C2和C3的變更。每應(yīng)用一個(gè)就創(chuàng)建一個(gè)新的提交。假設(shè)新創(chuàng)建的提交叫C2和C3。重要的是C2和C3是全新的提交擁有全新的SHA-1哈希值雖然變更內(nèi)容與C2、C3相同。移動(dòng)分支指針最后將feature分支指針移動(dòng)到最后一個(gè)新提交C3上。最終的歷史圖變成了C1 — C4 (master) \ C2 — C3 (feature)現(xiàn)在feature分支的歷史看起來就像是直接從最新的master上拉出來進(jìn)行開發(fā)的一樣歷史是一條完美的直線。此時(shí)再切回master執(zhí)行g(shù)it merge feature就會(huì)觸發(fā)一次快進(jìn)合并將master直接移到C3得到一條從C1到C4到C2到C3的線性歷史。重大警告變基的本質(zhì)是丟棄原有提交創(chuàng)建內(nèi)容相似但全新的提交。這意味著你重寫了提交歷史。絕對(duì)不要對(duì)已經(jīng)推送到遠(yuǎn)程倉庫、且可能被其他人使用的提交進(jìn)行變基這會(huì)導(dǎo)致你的本地歷史與遠(yuǎn)程歷史嚴(yán)重分歧在協(xié)作中引發(fā)災(zāi)難。變基只適用于你本地、尚未分享的提交用于整理你的本地工作使其更清晰后再推送。3.4 沖突的產(chǎn)生與解決當(dāng)Git無法自動(dòng)抉擇時(shí)在三方合并或變基重新應(yīng)用補(bǔ)丁的過程中如果Git發(fā)現(xiàn)兩組差異修改了同一個(gè)文件的同一行代碼或相鄰行它就無法自動(dòng)決定應(yīng)該采用哪個(gè)版本。這時(shí)合并過程會(huì)暫停并標(biāo)記為沖突狀態(tài)。底層發(fā)生了什么Git會(huì)在沖突的文件中插入沖突標(biāo)記 HEAD // master分支上的代碼 console.log(“Hello from master”); // feature分支上的代碼 console.log(“Hello from feature”); feature HEAD和之間是當(dāng)前分支你執(zhí)行合并命令時(shí)所在的分支即接收合并的分支的內(nèi)容。和 feature之間是要被合并進(jìn)來的分支的內(nèi)容。解決沖突的實(shí)質(zhì)就是手動(dòng)編輯這個(gè)文件刪除這些沖突標(biāo)記并決定最終保留的代碼應(yīng)該是什么樣子??赡苤槐A粢贿呉部赡軐蛇叺男薷娜诤稀=鉀Q完所有沖突文件后你需要用git add將解決后的文件標(biāo)記為已解決然后完成合并提交git commit或變基操作git rebase --continue。避坑技巧遇到?jīng)_突別慌。首先使用git status查看哪些文件有沖突。其次善用圖形化工具如VSCode、IntelliJ IDEA內(nèi)置的合并工具或命令行工具git mergetool它們可以并排顯示兩個(gè)版本的差異讓你更直觀地解決。解決后務(wù)必進(jìn)行編譯和測(cè)試確保合并后的代碼能正常工作。4. 遠(yuǎn)程協(xié)作推送與拉取背后的數(shù)據(jù)同步本地玩得再轉(zhuǎn)最終代碼也要和團(tuán)隊(duì)共享。這就涉及到與遠(yuǎn)程倉庫的交互push和pull以及fetch。它們的底層同樣是對(duì)象和引用的傳輸與更新。4.1 遠(yuǎn)程引用遠(yuǎn)程倉庫的鏡像指針當(dāng)你克隆一個(gè)倉庫或添加遠(yuǎn)程倉庫git remote add origin url后Git會(huì)在本地創(chuàng)建遠(yuǎn)程分支引用。它們位于.git/refs/remotes/remote-name/下例如origin/master。這個(gè)origin/master指針是你本地倉庫對(duì)遠(yuǎn)程倉庫master分支最后一次已知狀態(tài)的記錄。它在你執(zhí)行g(shù)it fetch或git pull時(shí)被更新。關(guān)鍵點(diǎn)origin/master是一個(gè)本地引用但它被Git特殊對(duì)待你不能直接在它上面提交。它只是一個(gè)“書簽”標(biāo)記著遠(yuǎn)程分支的位置。4.2 推送的本質(zhì)上傳對(duì)象更新遠(yuǎn)程引用git push origin feature這個(gè)命令做了兩件核心事情傳輸對(duì)象Git會(huì)找出你本地feature分支上有而遠(yuǎn)程origin倉庫的feature分支上沒有的所有Git對(duì)象Commit, Tree, Blob, Tag。將這些對(duì)象打包并上傳到遠(yuǎn)程服務(wù)器。更新遠(yuǎn)程引用請(qǐng)求遠(yuǎn)程服務(wù)器將其feature分支的指針更新為和你本地feature分支指向相同的提交??爝M(jìn)與非快進(jìn)推送這里就引出了常見的錯(cuò)誤! [rejected]。遠(yuǎn)程倉庫會(huì)檢查你的推送請(qǐng)求是否是一個(gè)“快進(jìn)”操作。即你要求遠(yuǎn)程feature分支從舊的提交A移動(dòng)到新的提交B那么A必須是B的直接祖先。如果是則允許快進(jìn)推送。如果不是即遠(yuǎn)程分支已經(jīng)有了你本地沒有的新提交則意味著歷史分叉直接推送會(huì)覆蓋別人的工作因此默認(rèn)被拒絕。如何處理非快進(jìn)推送被拒絕先拉取再合并這是標(biāo)準(zhǔn)流程。執(zhí)行g(shù)it pull origin feature這相當(dāng)于git fetchgit merge origin/feature將遠(yuǎn)程的新變更拉取到本地并與你的修改合并解決可能的沖突產(chǎn)生一個(gè)新的合并提交。此時(shí)你的本地歷史包含了遠(yuǎn)程的新內(nèi)容再推送就滿足快進(jìn)條件了。變基后推送如果你追求整潔歷史可以在拉取時(shí)使用git pull --rebase origin feature這會(huì)將你的本地提交變基到更新后的遠(yuǎn)程分支之上然后再推送。同樣要確保你的本地提交還沒有被推送到遠(yuǎn)程否則變基會(huì)重寫歷史給協(xié)作者帶來麻煩。強(qiáng)制推送使用git push --force或更安全的git push --force-with-lease。這會(huì)強(qiáng)制用你的本地分支覆蓋遠(yuǎn)程分支無視非快進(jìn)限制。這是危險(xiǎn)操作會(huì)覆蓋遠(yuǎn)程歷史只應(yīng)在你完全確定可以這樣做的情況下使用例如在個(gè)人特性分支上整理尚未合并的提交。4.3 拉取與抓取獲取遠(yuǎn)程更新的兩種方式很多人混淆git pull和git fetch其實(shí)它們區(qū)別很大。git fetch remote這是一個(gè)“只讀”操作。它聯(lián)系遠(yuǎn)程倉庫下載所有你本地還沒有的所有新對(duì)象提交、文件等并更新你本地的遠(yuǎn)程引用如origin/master。它不會(huì)動(dòng)你本地的任何工作區(qū)文件也不會(huì)動(dòng)你本地的分支指針如master。執(zhí)行完git fetch后你可以通過git log origin/master查看遠(yuǎn)程倉庫的最新進(jìn)展然后決定如何整合merge或rebase。git pull remote branch這是一個(gè)“復(fù)合”操作。它等價(jià)于git fetch加上git merge默認(rèn)或git rebase如果配置了pull.rebase或使用--rebase選項(xiàng)。即它先執(zhí)行g(shù)it fetch更新遠(yuǎn)程引用然后立即嘗試將遠(yuǎn)程分支的更新合并或變基到你當(dāng)前檢出的本地分支上。最佳實(shí)踐建議我個(gè)人的習(xí)慣是幾乎總是使用git fetch而不是git pull。因?yàn)閒etch更安全它讓你先看到遠(yuǎn)程發(fā)生了什么變化git log --oneline origin/master..master可以查看本地領(lǐng)先了哪些提交git log --oneline master..origin/master可以查看遠(yuǎn)程領(lǐng)先了哪些提交然后再從容地決定是合并 (git merge origin/master) 還是變基 (git rebase origin/master)。直接使用git pull有時(shí)會(huì)意外地創(chuàng)建一個(gè)你不想要的合并提交讓歷史圖變得混亂。4.4 圖解一次完整的協(xié)作流程假設(shè)你和同事都在develop分支上工作。你本地develop指向提交A遠(yuǎn)程origin/develop也指向A。你完成了工作提交了B和C。此時(shí)你本地develop指向Corigin/develop仍指向A。你執(zhí)行g(shù)it push origin develop。Git檢查A是C的祖先嗎是的A — B — C。所以是快進(jìn)推送允許。于是遠(yuǎn)程develop更新為C你的本地origin/develop引用也被更新為C在推送反饋中。與此同時(shí)你的同事也提交了D并成功推送?,F(xiàn)在遠(yuǎn)程develop指向D。你再次開發(fā)提交了E?,F(xiàn)在你想推送。執(zhí)行g(shù)it push origin develop。Git檢查C遠(yuǎn)程已知狀態(tài)是E的祖先嗎不是因?yàn)檫h(yuǎn)程已經(jīng)是C — D而你是C — E。歷史分叉了非快進(jìn)推送被拒絕。你執(zhí)行g(shù)it fetch origin。這會(huì)更新你本地的origin/develop指針讓它指向D?,F(xiàn)在你需要整合。你可以git merge origin/develop將D合并到你的E上可能會(huì)產(chǎn)生合并提交M。然后推送M。git rebase origin/develop將你的提交E“重新播放”在D之后產(chǎn)生新的提交E。然后推送E此時(shí)是快進(jìn)。選擇一種方式整合后再次推送成功。理解了這個(gè)數(shù)據(jù)流動(dòng)和引用更新的過程遠(yuǎn)程協(xié)作中的各種問題就都有了清晰的排查思路。5. 高級(jí)操作解析重置、檢出與貯藏的底層邏輯掌握了核心原理我們?cè)賮砜磶讉€(gè)日常中強(qiáng)大但容易誤用的命令你會(huì)對(duì)它們有全新的認(rèn)識(shí)。5.1 重置移動(dòng)分支指針與操作暫存區(qū)/工作區(qū)git reset是一個(gè)多功能命令它的行為主要由一個(gè)參數(shù)決定--soft、--mixed默認(rèn)、--hard。理解它的關(guān)鍵在于明白它操作的是三個(gè)區(qū)域當(dāng)前分支指針HEAD所指向的分支、暫存區(qū)索引、工作目錄。假設(shè)當(dāng)前分支master指向提交C歷史是 A — B — C。git reset --soft B這是最“溫柔”的模式。它只做一件事將當(dāng)前分支指針master從C移動(dòng)到B。暫存區(qū)和工作目錄保持不變。這意味著C引入的所有變更現(xiàn)在都顯示為“已暫存待提交”的狀態(tài)。這常用于將多個(gè)提交“壓縮”成一個(gè)——先reset到某個(gè)早期提交然后一次性提交所有變更。git reset --mixed B這是默認(rèn)模式。它做兩件事移動(dòng)分支指針到B同--soft。重置暫存區(qū)使其狀態(tài)與B一致。工作目錄保持不變。 這意味著C引入的所有變更現(xiàn)在都顯示為“已修改未暫存”的狀態(tài)。這常用于撤銷一次提交但保留修改內(nèi)容以便重新編輯和提交。這也是網(wǎng)絡(luò)熱詞中提到的在回退版本后再次執(zhí)行reset --mixed到之前版本號(hào)的操作意圖——它讓代碼回到工作區(qū)從而可以重新提交或處理。git reset --hard B這是最“強(qiáng)硬”的模式。它做三件事移動(dòng)分支指針到B。重置暫存區(qū)到B的狀態(tài)。重置工作目錄到B的狀態(tài)。這意味著C引入的所有變更以及任何未提交的暫存區(qū)和工作區(qū)修改都將被丟棄且難以恢復(fù)。此操作非常危險(xiǎn)務(wù)必在確認(rèn)不需要這些修改時(shí)使用。網(wǎng)絡(luò)熱詞中提到的回退操作第一步reset --hard就是為了徹底回到過去的某個(gè)版本狀態(tài)。重要警告git reset --hard會(huì)覆蓋工作目錄。如果你有未提交的修改即使是已暫存的它們會(huì)被永久丟棄。在執(zhí)行前請(qǐng)務(wù)必用git status確認(rèn)或者先將工作區(qū)貯藏git stash備份。5.2 檢出切換分支與還原文件git checkout也有兩個(gè)主要用途底層邏輯不同。切換分支git checkout feature。這本質(zhì)上是更新HEAD文件使其指向feature分支符號(hào)引用。用feature分支指向的提交對(duì)應(yīng)的Tree來更新暫存區(qū)和工作目錄。因此你的工作區(qū)文件會(huì)變成feature分支的最新樣子。如果當(dāng)前工作區(qū)或暫存區(qū)有未提交的修改且這些修改與要切換到的分支的內(nèi)容沖突Git會(huì)阻止你切換除非你貯藏或提交這些修改。檢出文件/提交git checkout commit-hash -- file.txt。這個(gè)命令非常有用它從指定的提交或暫存區(qū)如果省略提交哈希中取出某個(gè)文件的版本同時(shí)更新暫存區(qū)和工作目錄中的該文件。這常用于丟棄對(duì)某個(gè)文件的本地修改將其恢復(fù)到上次提交或暫存時(shí)的狀態(tài)。注意它不移動(dòng)任何分支指針。在較新版本的Git中推薦使用git switch來切換分支使用git restore來恢復(fù)文件以使命令的意圖更清晰。5.3 貯藏臨時(shí)保存工作現(xiàn)場(chǎng)git stash是一個(gè)“救火隊(duì)長(zhǎng)”。當(dāng)你在一個(gè)分支上工作到一半需要緊急切換到另一個(gè)分支處理問題時(shí)但當(dāng)前修改又沒完成、不想提交就可以使用貯藏。底層操作git stash或git stash pushGit會(huì)為你的工作目錄和暫存區(qū)的修改創(chuàng)建幾個(gè)通常是兩個(gè)新的提交。這些提交不在任何分支上但可以通過一個(gè)特殊的引用refs/stash來找到。然后它會(huì)執(zhí)行一個(gè)git reset --hard HEAD讓你的工作區(qū)變干凈。git stash list查看貯藏棧。git stash pop應(yīng)用最近一次貯藏的修改到當(dāng)前工作區(qū)并嘗試恢復(fù)暫存狀態(tài)然后從貯藏棧中刪除該記錄。git stash apply應(yīng)用貯藏但不從棧中刪除。貯藏的本質(zhì)是創(chuàng)建游離的提交來保存你的工作狀態(tài)。它是一個(gè)非常實(shí)用的功能但不宜長(zhǎng)期使用因?yàn)橘A藏的記錄容易遺忘。處理完緊急事務(wù)后應(yīng)及時(shí)回到原分支pop出貯藏內(nèi)容。6. 實(shí)戰(zhàn)從原理出發(fā)解決高頻疑難雜癥理解了原理很多網(wǎng)上搜到的“玄學(xué)”問題就能迎刃而解。我們分析幾個(gè)網(wǎng)絡(luò)熱詞中的典型問題。問題一cannot retrieve latest commit at this time.這個(gè)錯(cuò)誤常見于GitHub、GitLab等平臺(tái)或者某些IDE如Android Studio的Git插件中。它通常意味著你的本地Git客戶端無法從遠(yuǎn)程倉庫獲取到最新的提交信息。排查思路網(wǎng)絡(luò)問題首先檢查網(wǎng)絡(luò)連接。嘗試git fetch origin看命令行是否報(bào)更詳細(xì)的錯(cuò)誤。認(rèn)證問題如果你使用SSH檢查密鑰是否已添加且遠(yuǎn)程倉庫有公鑰。如果是HTTPS檢查密碼或令牌是否過期??梢試L試git remote -v查看遠(yuǎn)程地址并用git fetch remote-url測(cè)試。遠(yuǎn)程引用不一致有時(shí)本地緩存的遠(yuǎn)程分支引用 (origin/xxx) 可能過時(shí)或混亂??梢試L試git remote update origin --prune來更新并清理遠(yuǎn)程跟蹤分支。倉庫權(quán)限確認(rèn)你有該倉庫的讀取權(quán)限。IDE/工具緩存如果是IDE報(bào)錯(cuò)嘗試重啟IDE或使用命令行執(zhí)行Git操作來繞過IDE的Git集成可能存在的緩存或bug。問題二idea 中將dev分支的代碼合并到自己的分支這本質(zhì)上就是一個(gè)合并操作。假設(shè)你當(dāng)前在自己的feature/xxx分支上。標(biāo)準(zhǔn)流程確保你的分支工作區(qū)是干凈的沒有未提交的修改??梢杂胓it status查看。首先獲取遠(yuǎn)程最新代碼git fetch origin。然后將origin/dev遠(yuǎn)程dev分支合并到你的當(dāng)前分支git merge origin/dev。解決可能出現(xiàn)的沖突完成合并提交。使用變基保持整潔如果你想讓你分支的歷史看起來像是基于最新的dev開發(fā)的可以在第3步使用git rebase origin/dev。但同樣確保你的feature/xxx分支上的提交還沒有推送到遠(yuǎn)程或者你確定協(xié)作伙伴能接受歷史重寫。問題三drop commit 怎么找回“drop commit”通常發(fā)生在交互式變基 (git rebase -i) 中你選擇丟棄drop了某個(gè)提交?;蛘吣悴恍⌒挠胓it reset --hard回退并丟失了提交。找回的核心只要這個(gè)提交曾經(jīng)存在于你的倉庫中比如你提交過但還沒被垃圾回收它就可以被找到。Git不會(huì)立即刪除對(duì)象它有“回收站”機(jī)制。找回方法使用git reflog這是你的救命稻草。reflog記錄了HEAD和分支指針在過去一段時(shí)間內(nèi)的所有移動(dòng)記錄。執(zhí)行g(shù)it reflog找到對(duì)應(yīng)操作如“rebase”或“reset”之前的那個(gè)提交哈希。a1b2c3d HEAD{0}: reset: moving to HEAD~2 e4f5g6h HEAD{1}: commit: Your lost commit message ...上例中e4f5g6h就是你丟失的提交。使用git checkout e4f5g6h可以檢出一個(gè)臨時(shí)的游離狀態(tài)來查看它或者git branch recovery-branch e4f5g6h基于這個(gè)提交創(chuàng)建一個(gè)新分支來恢復(fù)它。使用git fsck --lost-found這個(gè)命令會(huì)列出所有“懸空”的對(duì)象即不被任何分支或標(biāo)簽引用的對(duì)象。你可以在.git/lost-found/目錄下找到它們但操作相對(duì)復(fù)雜。reflog通常是更簡(jiǎn)單直接的選擇。問題四cannot commit changes due to unresolved conflicts.這個(gè)錯(cuò)誤直白地告訴你存在未解決的沖突所以無法創(chuàng)建提交。解決步驟使用git status查看哪些文件處于“Unmerged paths”狀態(tài)。打開這些文件解決其中的沖突標(biāo)記,,。決定保留哪部分代碼或進(jìn)行融合。對(duì)每個(gè)解決完沖突的文件執(zhí)行g(shù)it add file。這告訴Git該文件的沖突已解決。當(dāng)所有沖突文件都已被add后就可以正常執(zhí)行g(shù)it commit來完成合并提交了。如果你是在變基過程中則使用git rebase --continue。整個(gè)過程的核心就是手動(dòng)完成Git無法自動(dòng)完成的三方合并決策并通過git add來確認(rèn)你的決策。