緣起
讀了“扔物線”老師的小冊《Git 原理詳解及實用指南》感覺收穫良多,於是想寫點東西做一個總結,即加深自己的印象也希望能給社群小夥伴一點幫助,寫的不對的地方還請多多指導。身為一個初入前端半年的菜鳥,由伊始的只知道git是用來託管程式碼的工具到逐步瞭解中央版本控制系統與分散式版本控制系統(git)的原理與區別;從之前只會基本的add、commit、pull、push操作到使用stash、merge、reset方便得不亦樂乎,都得益於對git原理的深入理解,逼話少說,咋們直接進入正題。前方長篇預警...
從瞭解版本控制系統開始
所謂版本控制,就是在檔案修改的歷程中保留修改歷史,可以方便的撤銷(如同文字編輯的撤銷操作一般,只是版本控制會複雜的多)之前對檔案的修改。一個版本控制系統的三個核心內容:版本控制(最基本的功能),主動提交(commit歷史)和遠端倉庫(協同開發)。
中央式版本控制系統(VCS)
工作模型
- 主工程師搭好專案框架
- 在公司伺服器建立一個遠端倉庫,並提交程式碼
- 其他人拉取程式碼,並行開發
- 每個人獨立負責一個功能,開發完成提交程式碼
- 其他人隨時拉取程式碼,保持同步
分散式版本控制系統(DVCS)
分散式與中央式的區別主要在於,分散式除了遠端倉庫之外團隊中每一個成員的機器上都有一份本地倉庫,每個人在自己的機器上就可以進行提交程式碼,檢視版本,切換分支等操作而不需要完全依賴網路環境。
工作模型
- 主工程師搭好專案框架 ,並提交程式碼到本地倉庫
- 在公司伺服器建立一個遠端倉庫,並將1的提交推送到遠端倉庫
- 其他人把遠端倉庫所有內容克隆到本地,擁有了各自的本地倉庫,開始並行開發
- 每個人獨立負責一個功能,可以把每一個小改動提交到本地(由於本地提交無需立即上傳到遠端倉庫,所以每一步提交不必是一個完整功能,而可以是功能中的一個步驟或塊)
- 功能開發完畢,將和這個功能相關的所有提交從本地推送到遠端倉庫
- 每次當有人把新的提交推送到遠端倉庫的時候,其他人就可以選擇把這些提交同步到自己的機器上,並把它們和自己的原生程式碼合併
分散式版本管理系統的優缺點:
優點
- 大多數操作本地進行,數度更快,不受網路與物理位置限制,不聯網也可以提交程式碼、檢視歷史、切換分支等等
- 分佈提交程式碼,提交更細利於review
缺點
- 初次clone時間較長
- 本地佔用儲存高於中央式系統
繼續深入git原理
假設你已經安裝好了git並將程式碼clone到了本地,新手移步git安裝與程式碼拷貝指南。
git最基本的工作模型
首先理解三個基本概念:
- 工作區:就是你在電腦裡能看到的目錄
- 版本庫:工作區有一個隱藏目錄.git,這個不算工作區,而是Git的本地版本庫,你的所有版本資訊都會存在這裡
- 暫存區:英文叫stage, 或index。一般存放在 ".git目錄下" 下的index檔案(.git/index)中,所以我們把暫存區有時也叫作索引(index)
1.首先新建一個test.txt檔案並對其進行修改,通過status可以檢視工作目錄當前狀態,此時test.txt對git來說是不存在的(Untracked) 2.然後通過add命令將修改放入暫存區(git開始追蹤它) 可以看到,test.txt 的文字變成了綠色,它的前面多了「new file:」的標記,而它的描述也從 "Untracked files" 變成了 "Changes to be commited"。這些都說明一點:test.txt 這個檔案的狀態從 "untracked"(未跟蹤)變成了 "staged"(已暫存),意思是這個檔案中被改動的部分(也就是這整個檔案)被記錄進了 staging area(暫存區)
stage 這個詞在 Git 裡,是「集中收集改動以待提交」的意思;而 staging area ,就是一個「彙集待提交的檔案改動的地方」。簡稱「暫存」和「暫存區」。至於 staged 表示「已暫存」,就不用再解釋了吧?3.現在檔案已經放入暫存區,可以用commit命令提交: 在這裡你也可以直接commit提交會進入commit資訊編輯頁面,而加上-m引數可以快捷輸入簡短的提交備註資訊,這樣你就完成了一次提交(可以通過
git log
檢視提交歷史)接著對該檔案再次進行修改,輸入
git status
可以看到,該檔案 又變紅了,不過這次它左邊的文字不是 "New file:" 而是 "modified:",而且上方顯示它的狀態也不是 "Untracked" 而是 "not staged for commit",意思很明確:Git 已經認識這個檔案了,它不是個新檔案,但它有了一些改動。所以雖然狀態的顯示有點不同,但處理方式還是一樣的:
接下來再次將該檔案add、commit,檢視log可以看到已經存在兩條提交記錄
4.最後通過push把本地的所有commit上傳到遠端倉庫:
團隊工作基本模型
工作模型
1.在上面基本操作的基礎上,同事 commit 程式碼到他的本地,並 push 到遠端倉庫
2.你把遠端倉庫新的提交通過 pull指令拉取到你的本地
通過這個流程,你和同事就可以簡單地合作了:你寫了程式碼,commit,push 到遠端倉庫,然後他 pull 到他的本地;他再寫程式碼,commit, push 到遠端倉庫,然後你再 pull 到你的本地。你來我往,配合得不亦樂乎。(但是有時候push會失敗)
為什麼會失敗?
因為 Git 的push 其實是用本地倉庫的commit記錄去覆蓋遠端倉庫的commit記錄(注:這是簡化概念後的說法,push 的實質和這個說法略有不同),而如果在遠端倉庫含有本地沒有的commit的時候,push (如果成功)將會導致遠端的commit被擦掉。這種結果當然是不可行的,因此 Git 會在 push 的時候進行檢查,如果出現這樣的情況,push 就會失敗
這時只需要先通過git pull
(實為fetch和merge的組合操作)將本地倉庫的提交和遠端倉庫的提交進行合併,然後再push就可以了
Feature Branching:最流行的工作流
核心:
(1)任何新的功能(feature)或 bug 修復全都新建一個 branch 來寫;
(2)branch 寫完後,合併到 master,然後刪掉這個 branch(可使用git origin -d 分支名
刪除遠端倉庫的分支)。
(1)程式碼分享:寫完之後可以在開發分支review之後再merge到master分支
(2)一人多工:當正在開發接到更重要的新任務時,你只要稍微把目前未提交的程式碼簡單收尾一下,然後做一個帶有「未完成」標記的提交(例如,在提交資訊裡標上「TODO」),然後回到 master 去建立一個新的 branch 進行開發就好了。
HEAD、branch、引用的本質以及push的本質
HEAD:當前commit的引用
當前 commit 在哪裡,HEAD 就在哪裡,這是一個永遠自動指向當前 commit 的引用,所以你永遠可以用 HEAD 來操作當前 commit,
branch:
HEAD 是 Git 中一個獨特的引用,它是唯一的。而除了 HEAD 之外,Git 還有一種引用,叫做 branch(分支)。HEAD 除了可以指向 commit,還可以指向一個branch,當指向一個branch時,HEAD會通過branch間接指向當前commit,HEAD移動會帶著branch一起移動:
branch 包含了從初始 commit 到它的所有路徑,而不是一條路徑。並且,這些路徑之間也是彼此平等的。
像上圖這樣,master 在合併了 branch1 之後,從初始 commit 到 master 有了兩條路徑。這時,master 的串就包含了 1 2 3 4 7 和 1 2 5 6 7 這兩條路徑。而且,這兩條路徑是平等的,1 2 3 4 7 這條路徑並不會因為它是「原生路徑」而擁有任何的特別之處建立branch:
git branch 名稱
切換branch:
git checkout 名稱
(將HEAD指向該branch)建立+切換:
git checkout -b 名稱
在切換到新的 branch 後,再次 commit 時 HEAD 就會帶著新的 branch 移動了: 而這個時候,如果你再切換到 master 去 commit,就會真正地出現分叉了: 刪除branch:
git branch -d 名稱
注意:
(1)HEAD 指向的 branch 不能刪除。如果要刪除 HEAD 指向的 branch,需要先用 checkout 把 HEAD 指向其他地方。
(2)由於 Git 中的 branch 只是一個引用,所以刪除 branch 的操作也只會刪掉這個引用,並不會刪除任何的 commit。(不過如果一個 commit 不在任何一個 branch 的「路徑」上,或者換句話說,如果沒有任何一個 branch 可以回溯到這條 commit(也許可以稱為野生 commit?),那麼在一定時間後,它會被 Git 的回收機制刪除掉)
(3)出於安全考慮,沒有被合併到 master 過的 branch 在刪除時會失敗(怕誤刪未完成branch)把-d換成-D可以強制刪除
引用的本質
所謂引用,其實就是一個個的字串。這個字串可以是一個 commit 的 SHA-1 碼(例:c08de9a4d8771144cd23986f9f76c4ed729e69b0),也可以是一個 branch(例:ref: refs/heads/feature3)。
Git 中的 HEAD 和每一個 branch 以及其他的引用,都是以文字檔案的形式儲存在本地倉庫 .git 目錄中,而 Git 在工作的時候,就是通過這些文字檔案的內容來判斷這些所謂的「引用」是指向誰的。
push的本質:把 branch 上傳到遠端倉庫
(1)把當前branch位置上傳到遠端倉庫,並把它路徑上的commits一併上傳
(2)git中(2.0及以後版本),git push
不加引數只能上傳到從遠端倉庫clone或者pull下來的分支,如需push在本地建立的分支則需使用git push origin 分支名
的命令
(3)遠端倉庫的HEAD並不隨push與本地一致,遠端倉庫HEAD永遠指向預設分支(master),並隨之移動(可以使用git br -r
檢視遠端分支的HEAD指向)。
開啟git操作之旅
merge:合併
含義:從目標 commit 和當前 commit (即 HEAD 所指向的 commit)分叉的位置起,把目標 commit 的路徑上的所有 commit 的內容一併應用到當前 commit,然後自動生成一個新的 commit。
當執行git merge branch1
操作,Git 會把 5 和 6 這兩個 commit 的內容一併應用到 4 上,然後生成一個新的提交 7 。merge的特殊情況:
(1)merge衝突:你的兩個分支改了相同的內容,Git 不知道應該以哪個為準。如果在 merge 的時候發生了這種情況,Git 就會把問題交給你來決定。具體地,它會告訴你 merge 失敗,以及失敗的原因;這時候你只需要手動解決掉衝突並重新add、commit(改動不同檔案或同一檔案的不同行都不會產生衝突);或者使用
git merge --abort
放棄解決衝突,取消merge(2)HEAD 領先於目標 commit:merge是一個空操作: 此時merge不會有任何反應。
(3)HEAD 落後於 目標 commit且不存在分支(fast-forward):
git會直接把HEAD與其指向的branch(如果有的話)一起移動到目標commit。
rebase:給commit序列重新設定基礎點
有些人不喜歡 merge,因為在 merge 之後,commit 歷史就會出現分叉,這種分叉再匯合的結構會讓有些人覺得混亂而難以管理。如果你不希望 commit 歷史出現分叉,可以用 rebase 來代替 merge。
可以看出,通過 rebase,5 和 6 兩條 commits 把基礎點從 2 換成了 4 。通過這樣的方式,就讓本來分叉了的提交歷史重新回到了一條線。這種「重新設定基礎點」的操作,就是 rebase 的含義。另外,在 rebase 之後,記得切回 master 再 merge 一下,把 master 移到最新的 commit。為什麼要從 branch1 來 rebase,然後再切回 master 再 merge 一下這麼麻煩,而不是直接在 master 上執行 rebase?
這就導致 master 上之前的兩個最新 commit (3和4)被剔除了。如果這兩個 commit 之前已經在遠端倉庫存在,這就會導致沒法 push : 所以,為了避免和遠端倉庫發生衝突,一般不要從 master 向其他 branch 執行 rebase 操作。而如果是 master 以外的 branch 之間的 rebase(比如 branch1 和 branch2 之間),就不必這麼多費一步,直接 rebase 就好。
從圖中可以看出,rebase 後的每個 commit 雖然內容和 rebase 之前相同,但它們已經是不同的 commit 了(每個commit有唯一標誌)。如果直接從 master 執行 rebase 的話,就會是下面這樣:
需要說明的是,rebase 是站在需要被 rebase 的 commit 上進行操作,這點和 merge 是不同的。
stash:臨時存放工作目錄的改動
stash 指令可以幫你把工作目錄的內容全部放在你本地的一個獨立的地方,它不會被提交,也不會被刪除,你把東西放起來之後就可以去做你的臨時工作了,做完以後再來取走,就可以繼續之前手頭的事了。
操作步驟:
(1)git stash
可以加上save引數後面帶備註資訊(git stash save '備註資訊'
)
(2)此時工作目錄已經清空,可以切換到其他分支幹其他事情了
(3)git stash pop
彈出第一個stash(該stash從歷史stash中移除);或者使用git stash apply
達到相同的效果(該stash仍存在stash list中),同時可以使用git stash list
檢視stash歷史記錄並在apply後面加上指定的stash返回到該stash。
注意:沒有被track的檔案會被git忽略而不被stash,如果想一起stash,加上-u引數。
reflog:引用記錄的log
可以檢視git的引用記錄,不指定引數,預設顯示HEAD的引用記錄;如果不小心把分支刪掉了,可以使用該命令檢視引用記錄,然後使用checkout切到該記錄處重建分支即可。
注意:不再被引用直接或間接指向的 commits 會在一定時間後被 Git 回收,所以使用 reflog 來找回被刪除的 branch 的操作一定要及時,不然有可能會由於 commit 被回收而再也找不回來。
看看我都改了什麼
log:檢視已提交內容
git log -p
可以檢視每個commit的改動細節(到改動檔案的每一行)
git log --stat
檢視簡要統計(哪幾個檔案改動了)
git show 指定commit 指定檔名
檢視指定commit的指定檔案改動細節
diff:檢視未提交內容
git diff --staged
可以顯示暫存區和上一條提交之間的不同。換句話說,這條指令可以讓你看到「如果你立即輸入 git commit,你將會提交什麼」
git diff
可以顯示工作目錄和暫存區之間的不同。換句話說,這條指令可以讓你看到「如果你現在把所有檔案都 add,你會向暫存區中增加哪些內容」
git diff HEAD
可以顯示工作目錄和上一條提交之間的不同,它是上面這二者的內容相加。換句話說,這條指令可以讓你看到「如果你現在把所有檔案都 add 然後 git commit,你將會提交什麼」(不過需要注意,沒有被 Git 記錄在案的檔案(即從來沒有被 add 過的檔案,untracked files 並不會顯示出來。因為對 Git 來說它並不存在)實質上,如果你把 HEAD 換成別的commit,也可以顯示當前工作目錄和這條 commit 的區別。
剛剛提交的程式碼發現寫錯了怎麼辦?
再提一個修復了錯誤的commit?可以是可以,不過還有一個更加優雅和簡單的解決方法:commit --amend。
具體做法:
(1)修改好問題
(2)將修改add到暫存區
(3)使用git commit --amend
提交修改,結果如下圖:
錯誤不是最新的提交而是倒數第二個?
使用rebase -i(互動式rebase):
所謂「互動式 rebase」,就是在 rebase 的操作執行之前,你可以指定要 rebase 的 commit 鏈中的每一個 commit 是否需要進一步修改,那麼你就可以利用這個特點,進行一次「原地 rebase」。
操作過程:
(1)git rebase -i HEAD^^
說明:在 Git 中,有兩個「偏移符號」: ^ 和 ~。
^ 的用法:在 commit 的後面加一個或多個 ^ 號,可以把 commit 往回偏移,偏移的數量是 ^ 的數量。例如:master^ 表示 master 指向的 commit 之前的那個 commit; HEAD^^ 表示 HEAD 所指向的 commit 往前數兩個 commit。
~ 的用法:在 commit 的後面加上 ~ 號和一個數,可以把 commit 往回偏移,偏移的數量是 ~ 號後面的數。例如:HEAD~5 表示 HEAD 指向的 commit往前數 5 個 commit。
上面這行程式碼表示,把當前 commit ( HEAD 所指向的 commit) rebase 到 HEAD 之前 2 個的 commit 上:
(2)進入編輯頁面,選擇commit對應的操作,commit為正序排列,舊的在上,新的在下,前面黃色的為如何操作該commit,預設pick(直接應用該commit不做任何改變),修改第一個commit為edit(應用這個 commit,然後停下來等待繼續修正)然後:wq退出編輯頁面,此時rebase停在第二個commit的位置,此時可以對內容進行修改: (3)修改完後使用add,commit --amend將修改提交(4)
git rebase --continue
繼續 rebase 過程,把後面的 commit 直接應用上去,這次互動式 rebase 的過程就完美結束了,你的那個倒數第二個寫錯的 commit 就也被修正了:
想直接丟棄某次提交?
reset --hard 丟棄最新的提交
git reset --hard HEAD^
HEAD^ 表示 HEAD 往回數一個位置的 commit ,上節剛說過,記得吧?
用互動式 rebase 撤銷歷史提交
操作步驟與修改歷史提交類似,第二步把需要撤銷的commit修改為drop,其他步驟不再贅述。
用 rebase --onto 撤銷提交
git rebase --onto HEAD^^ HEAD^ branch1
上面這行程式碼的意思是:以倒數第二個 commit 為起點(起點不包含在 rebase 序列裡),branch1 為終點,rebase 到倒數第三個 commit 上。
錯誤程式碼已經push?
有的時候,程式碼 push 到了遠端倉庫,才發現有個 commit 寫錯了。這種問題的處理分兩種情況:
出錯內容在自己的分支
假如是某個你自己獨立開發的 branch 出錯了,不會影響到其他人,那沒關係用前面幾節講的方法把寫錯的 commit 修改或者刪除掉,然後再 push 上去就好了。但是此時會push報錯,因為遠端倉庫包含本地沒有的 commits(在本地已經被替換或被刪除了),此時直接使用git push origin 分支名 -f
強制push。
問題內容已合併到master
(1)增加新提交覆蓋之前內容
(2)使用git revert 指定commit
它的用法很簡單,你希望撤銷哪個 commit,就把它填在後面。如:git revert HEAD^
上面這行程式碼就會增加一條新的 commit,它的內容和倒數第二個 commit 是相反的,從而和倒數第二個 commit 相互抵消,達到撤銷的效果。在 revert 完成之後,把新的 commit 再 push 上去,這個 commit 的內容就被撤銷了。它和前面所介紹的撤銷方式相比,最主要的區別是,這次改動只是被「反轉」了,並沒有在歷史中消失掉,你的歷史中會存在兩條 commit :一個原始 commit ,一個對它的反轉 commit。
reset:不止可以撤銷提交
git reset --hard 指定commit
你的工作目錄裡的內容會被完全重置為和指定commit位置相同的內容。換句話說,就是你的未提交的修改會被全部擦掉。
git reset --soft 指定commit
會在重置 HEAD 和 branch 時,保留工作目錄和暫存區中的內容,並把重置 HEAD 所帶來的新的差異放進暫存區。
什麼是「重置 HEAD 所帶來的新的差異」?就是這裡:
git reset --mixed(或者不加引數) 指定commit
保留工作目錄,並且清空暫存區。也就是說,工作目錄的修改、暫存區的內容以及由 reset 所導致的新的檔案差異,都會被放進工作區。簡而言之,就是「把所有差異都混合(mixed)放在工作區中」。
checkout:簽出指定commit
checkout的本質是簽出指定的commit,不止可以切換branch還可以指定commit作為引數,把HEAD移動到指定的commit上;與reset的區別在於只移動HEAD不改變繫結的branch;git checkout --detach
可以把 HEAD 和 branch 脫離,直接指向當前 commit。
最後
希望我的總結能給大家帶來些許幫助,也希望和大家一起學以致用,一起成長。最後,萬分感謝扔老師的小冊,強勢安利《git原理詳解與實用指南》,認準扔物線。