“鋒哥,Git有什么可說的,不就是git add
添加,git commit
提交嘛” 聽說我要寫一篇Git教程,小明不屑一顧地說。
“..."。
小明是我的一個學生。目前,是一名Android開發工程師。
過了幾天,我又再次見到了小明。
“鋒哥,今天,我在Github新建了一個版本庫,本地提交后推送遠程的時候,卻被拒絕了,是怎么回事?”
以下是小明的操作記錄:
git init
git add .
git commit -m "Init commit"
git remote add origin git@github.com:xiaoming/xxx.git
git pull origin master
以上操作觸發了下面的錯誤:
From git@github.com:xiaoming/xxx.git
* branch master -> FETCH_HEAD
* [new branch] master -> origin/master
fatal: refusing to merge unrelated histories
“小明,注意看最后一句提示。翻譯成中文的意思是 ‘拒絕合并不相關的歷史’,這個問題有兩個方案可以處理。"
-
git pull
命令其實是觸發了拉取git fetch
和合并git merge
兩個操作。而本地的版本庫和遠程版本庫在第一次拉取或推送完成之前是毫不相關的,Git為了避免不必要的合并,默認不允許進行這樣的操作。但你可以手動添加--allow-unrelated-histories
強制進行合并,這是方案一。
git pull origin master --allow-unrelated-histories
- 再來看方案二,從你上面的操作來看,你只是在本地初始化了一個版本庫,并完成了基礎的提交。接下來,你希望和遠程版本庫建立關聯,將提交推送到遠程。這種情況下,其實你可能并不需要遠程的默認數據(通常是一個空的README文件)。所以,你可以添加
-f
參數,將提交強制提交并覆蓋遠程版本庫。
git push -f origin master
小明若有所思地點點頭,這是小明第一次遇到Git問題。我想,接下來他應該會比較順利了。
沒想到,過了幾天,我又收到了小明的消息。這一次,他發來的是對Git的抱怨。
“鋒哥,Git好討厭,提交日志出現了錯誤,也不能修改。你知道搜狗輸入法有時候不夠智能,輸入太快不小心就輸錯了...??”
“??,你這孩子,別輕易下結論哈。其實,Git是允許修改提交記錄的。使用Git最舒服的一點就是:Git永遠都會給你反悔的機會。這一點,其它的版本控制工具是做不到的!”
“哦,原來是這樣??!那快說說看,要怎么做?” 小明已經一副迫不及待的表情了。
“git commit
命令中有一個參數叫--amend
就是為解決這個問題而生的。因此,如果是最近的提交,你只需要按照下面的命令操作即可?!?/p>
git commit --amend -m "這是新的提交日志"
看完我的消息,小明給我發來一個微笑的表情。小明的抱怨讓我想起一句好氣又好笑的農村俗語 “屙屎不出怪茅坑”,哈哈。
本以為一切可以風平浪靜了。沒想到,過了一個月左右,突然接到了小明的緊急電話。電話那頭,小明似乎心情很急躁。
“鋒哥,我不小心進行了還原操作,我寫的代碼全丟了。幾千行的代碼啊,明天晚上就要發版本了,有辦法找回來嗎?”
聽到這個消息,我心里盤算,大約有50%的概率應該是找不回來了。這孩子比較粗心,可能根本就沒提交到版本庫。但如果他正好提交到了版本庫,興許還有救。因此,我安慰他說 “小明,別急!你打開TeamViewer,我遠程幫你看看”
連上機器后,我使用history
命令看到小明在提交之后使用了git reset --hard xxx
命令進行重置。--hard
是git reset
命令中唯一一個不安全的操作,它會真正地銷毀數據,以至于你在git log
中完全看不到操作日志??墒牵珿it真的很聰明,它還保存了另外一份日志叫reflog
,這個日志記錄了你每次修改HEAD的操作。因此,你可以通過下面的命令對數據進行還原:
git reflog
// 使用這個命令,你看到的日志大概是這樣
c8278f9 (HEAD -> master) HEAD@{0}: reset: moving to c8278f9914a91e3aca6ab0993b48073ba1e41b2b
3e59423 HEAD@{1}: commit: a
c8278f9 (HEAD -> master) HEAD@{2}: commit (amend): v2 update
2dc167b HEAD@{3}: commit: v2
2e342e9 HEAD@{4}: commit (initial): Init commit
可以看到,我們在版本3e59423
進行了git reset
操作,最新版本是3e59423
。因此,我們可以再次通過git reset
命令回到這個版本:
git reset --hard 3e59423
以上操作完成后,你會驚喜地發現,丟失的數據居然神奇般地回來了。
“?? ?? ??”
“下次別這樣操作了哈。另外,你怎么一次性丟失這么多代碼。一定要記得勤提交?!?小明出現這樣的問題,與平時的不規范操作也是分不開的。因此,最后我還不忘囑咐了他一句。
“好的,我知道了。對了,我一個還有比較疑惑的問題。git checkout
和git reset
到底有啥區別?我以前用SVN的時候git checkout
是用來檢出代碼的,在Git中可以用它切換分支或者指定版本,但git reset
同樣可以做到。難道兩者是完全一樣的嗎?” 小明在QQ中給我發來了回復消息。
“這是一個比較有深度的問題,解釋這個問題需要一點時間。接下來,你仔細聽”
理解Git工作空間
理解這個問題之前,先來簡單學習一些Git基礎知識。Git有三種狀態:
- 已提交(commited):數據已完全保存到本地數據庫中
- 已修改(modified):修改了文件,但還沒有保存到數據庫中
- 已暫存(staged):對一個已修改的文件做了標記,將包含在下一次提交的版本快照中
這三種狀態對應Git三個工作區域:Git版本庫、暫存區和工作區
Git版本庫是Git用來保存項目的元數據和對象數據庫的地方,使用git clone
命令時拷貝的就是這里的數據。
工作目錄是對某個版本獨立檢出的內容,這些數據可以供你使用和修改。
暫存區在Git內部對應一個名為index的文件,它保存了下次將要提交的文件列表信息。因此,暫存區有時候也被叫作 “索引”。
一個基礎的Git工作流程如下:
1)在工作區修改文件
2)使用git add
將文件添加到暫存區,也就是記錄到index
文件中
3)使用git commit
將暫存區中記錄的文件列表,使用快照永久地保存到Git版本庫中
理解HEAD
解釋這個問題,你還需要簡單理解HEAD是什么。簡單來說,HEAD是當前分支引用的指針,它永遠指向該分支上最后一次提交。為了讓你更容易理解HEAD,你可以將HEAD看作上一次提交數據的快照。
如果你感興趣,你可以使用一個底層命令來查看當前HEAD的快照信息:
git ls-tree -r HEAD
100644 blob aca4b576b7d4534266cb818ab1191d91887508b9 demo/src/main/java/com/youngfeng/snake/demo/Constant.java
100644 blob b8691ec87867b180e6ffc8dd5a7e85747698630d demo/src/main/java/com/youngfeng/snake/demo/SnakeApplication.java
100644 blob 9a70557b761171ca196196a7c94a26ebbec89bb1 demo/src/main/java/com/youngfeng/snake/demo/activities/FirstActivity.java
100644 blob fab8d2f5cb65129df09185c5bd210d20484154ce demo/src/main/java/com/youngfeng/snake/demo/activities/SecondActivity.java
100644 blob a7509233ecd8fe6c646f8585f756c74842ef0216 demo/src/main/java/com/youngfeng/snake/demo/activities/SplashActivity.java
這里簡單解釋一下每個字段的意思:100644表示文件模式,其對應一個普通文件。blob表示Git內部存儲對象數據類型,另外還有一種數據類型tree,對應一個樹對象,中間較長的字符串對應當前文件的SHA-1值,這部分不需要記住,簡單了解即可。
所以,簡單來說,HEAD對應一個樹形結構,存儲了當前分支所有的Git對象快照:
我們用一個表格簡單來總結一下以上知識點:
HEAD | Index(暫存區) | 工作區 |
---|---|---|
上一次提交的快照,下一次提交的父節點 | 預期的下一次提交快照 | 當前正在操作的沙盒目錄 |
理解git reset
和git checkout
區別主要是理解Git內部是怎么操作以上三棵樹的。
接下來,我們用一個簡單的例子來看一下使用git reset
到底發生了什么。先創建一個Git版本庫并觸發三次提交:
git init repo
touch file.txt
git add file.txt
git commit -m "v1"
echo v2 > file.txt
git add file.txt
git commit -m "v2"
echo v3 > file.txt
git add file.txt
git commit -m "v3"
以上操作完成后,版本庫現在看起來是這樣的:
接下來執行命令git reset 14ad152
看看會發生什么。以下是命令執行完成后看到的結果:
git log --abbrev-commit --pretty=oneline
### This is output ###
14ad152 (HEAD -> master) v2
bcc49f4 v1
git status -s
### This is output ###
M file.txt
cat file.txt
### This is output ###
v3
可以看到版本庫中文件版本回退到了V2,工作區文件內容同之前的版本V3一致;為了確認暫存區發生了什么變化,我們再使用一個底層命令對比一下暫存區數據和版本庫數據是否一致:
# 查看暫存區信息
git ls-files -s
### This is output ###
100644 8c1384d825dbbe41309b7dc18ee7991a9085c46e 0 file.txt
# 查看版本庫快照信息
git ls-tree -r HEAD
### This is output ###
100644 blob 8c1384d825dbbe41309b7dc18ee7991a9085c46e file.txt
可以看到當前版本庫和暫存區信息是完全一致的,HEAD指向了v2提交,用一個圖形來表示整個過程,應該是這樣:
看一眼上圖,理解一下剛剛發生的事情:首先,HEAD指針發生了移動,指向了V2,并撤銷了上一次提交。目前,版本庫和暫存區都保存的是第二次提交的記錄,工作區卻保存了最近一次修改。稍微聯想一下,你就會發現,這次的git reset
命令恰好是最近一次提交的逆向操作。讓數據完全回到了上一次提交前的狀態。所以,如果你想撤銷最近一次提交,可以這么做。
增加--soft參數測試
以上是我們對git reset
命令的第一次嘗試,在下一輪嘗試前,先執行git help reset
看看reset
命令的用法:
git reset [-q] [<tree-ish>] [--] <paths>...
git reset (--patch | -p) [<tree-ish>] [--] [<paths>...]
git reset [--soft | --mixed [-N] | --hard | --merge | --keep] [-q] [<commit>]
看最后一句發現,reset
命令后面還可以接5個不同的參數: --soft
、--mixed
、--hard
、--merge
、--keep
。這里我們主要關注前面三個,其中--mixed
其實剛剛已經嘗試過,它和不帶參數的git reset
命令是同樣的效果。換而言之,--mixed
是git reset
命令的默認行為。接下來執行git reset --soft 14ad152
看看會發生什么。命令執行完成后,按照慣例,我們同樣使用基礎命令看看發生了什么變化:
git log --abbrev-commit --pretty=oneline
### This is output ###
14ad152 (HEAD -> master) v2
bcc49f4 v1
git status -s
### This is output ###
M file.txt
cat file.txt
### This is output ###
v3
奇怪了?為什么會和上次不帶任何參數的執行結果完全一致?難道Git出現了設計錯誤。相信你看到結果一定會有這樣的疑問,其實不然!因為,這里我用文本粘貼了輸出結果,忽略了命令的字體顏色,其實這里第二條命令輸出結果中的M顏色與上一次執行結果是不一樣的。為了讓你看到不同,看下面的截圖:
這個顏色表示:file.txt文件已經被添加到了暫存區,使用
git commit
命令就可以完成提交。為了嚴謹,我們依然使用上面的底層命令看看版本庫和暫存區信息是否一致。注意:這里的結果應該是不一致才對,因為版本庫記錄的文件版本是v2,而暫存區記錄的文件版本其實是v3。
git ls-tree -r HEAD
### This is output ###
100644 blob 8c1384d825dbbe41309b7dc18ee7991a9085c46e file.txt
git ls-files -s
### This is output ###
100644 29ef827e8a45b1039d908884aae4490157bcb2b4 0 file.txt
可以看到,兩個命令執行輸出的SHA-1并不一致,驗證了我們的猜想。
這里我們可以得出一個結論:--soft
和默認行為(--mixed
)不一樣的地方是:--soft
會將工作區的最新文件版本再做一步操作,添加到暫存區。使用這個命令可以用來合并提交。即:如果你在某一次提交中有未完成的工作,而你反悔了,你可以使用這個命令撤銷提交,等工作做完后繼續一次性完成提交。
增加--hard參數測試
接下來我們對最后一個參數進行測試,這也是小明在使用過程出現問題的一個參數。執行命令git reset --hard 14ad152
,看看發生了什么:
git log --abbrev-commit --pretty=oneline
### This is output ###
14ad152 (HEAD -> master) v2
bcc49f4 v1
git status -s
### This is output ###
>>> No output <<<
cat file.txt
v2
注意看,這次使用git status -s
完全看不到輸出,這就證明:當前工作區,暫存區,版本庫數據是完全一致的。查看文件內容,發現文件回到了v2版本。通常情況下,如果你看到這種情況,一定會嚇一跳,你最近一次提交的數據居然完全丟失了。的確,這是Git命令中少有的幾個真正銷毀數據的命令之一。除非你非常清楚地知道自己在做什么,否則,請盡量不要使用這個命令!
我們依然用一張圖,完整地描述這個命令到底發什么了什么:
可以看到,相對于默認行為,--hard
將工作區的數據也還原到了V2版本,以至于V3版本的提交已經完全丟失。
git checkout
接下來看git checkout
, 按照慣例,先執行git checkout 14ad152
看看會發生什么:
git log --abbrev-commit --pretty=oneline
### This is output ###
14ad152 (HEAD -> master) v2
bcc49f4 v1
git status -s
### This is output ###
>>> No output <<<
cat file.txt
v2
可以看到,又出現了神奇的一幕,這一次git checkout
命令的執行結果的確和git reset --hard
完全一致。這是否意味著兩者就沒有任何區別了呢?當然也不是。嚴格來說,兩者有兩個“本質”的區別:
- 相對而言,
git checkout
對工作目錄是安全的,它不會將工作區已經修改的文件還原,git reset
則不管三七二十一一股腦全部還原。 - 另外一個比較重要的區別是,
git checkout
并不移動HEAD分支的指向,它是通過直接修改HEAD引用來完成指針的指向。
第二個不同點相對比較難理解,我們用一張圖來更直觀地展示二者的區別:
簡單來說,git reset
會通過移動指針來完成HEAD的指向,而git checkout
則通過直接修改HEAD本身來完成指向的移動。
命令作用于部分文件
git reset
和git checkout
還可以作用于一個文件,或者部分文件,即帶文件路徑執行。這種情況下,兩個命令的表現不太一樣。我們來試試看,先執行git reset 14ad15 -- file.txt
命令嘗試將文件恢復到V2版本。命令執行完成,按照慣例用一些基礎命令來看看發生了什么:
git log --abbrev-commit --pretty=oneline
### This is output ###
4521405 (HEAD -> master) v3
14ad152 v2
bcc49f4 v1
git status -v
### This is output ###
diff --git a/file.txt b/file.txt
index 29ef827..8c1384d 100644
--- a/file.txt
+++ b/file.txt
@@ -1 +1 @@
-v3
+v2
cat file.txt
v3
可以看到,版本庫和工作區的數據都沒有發生變化。唯一發生變化的是暫存區,暫存區記錄下一次提交的改動將導致數據從V3恢復到V2版本!
這里我們可以這樣理解:執行這條命令后,Git先將暫存區和工作區的文件版本恢復到V2,再將工作區的文件版本恢復到V3。與--hard
不一樣的地方是:這個命令并不會覆蓋工作區已經修改的文件,是安全操作。
執行帶路徑的git checkout
命令和git reset
命令有一些細微的差別,相對于git reset
,git checkout
帶路徑執行會覆蓋工作區已經修改的內容,導致數據丟失,是一個非安全操作。
針對上面的所有實驗,我們用一個簡單的表格來總結他們的區別,以及操作是否安全:
不帶路徑執行
命令行 | HEAD | 暫存區 | 工作區 | 目錄安全 |
---|---|---|---|---|
git reset [--mixed] | YES | YES | NO | YES |
git reset --soft | YES | YES | NO | YES |
git reset --hard | YES | YES | YES | NO |
git checkout | Modify | YES | YES | YES |
帶路徑執行
命令行 | HEAD | 暫存區 | 工作區 | 目錄安全 |
---|---|---|---|---|
git reset -- | NO | YES | NO | YES |
git checkout | NO | YES | YES | NO |
注意:執行非目錄安全的命令操作的時候,一定要慎重,除非你非常清楚自己在做什么!
“小明,你明白了嗎?” 消息發送過去之后,等了很久卻一直沒有響應。
“哎,這孩子!估計聽睡著了... ??”
自從這次問到Git的問題后,已經兩年過去了,小明再沒有問到關于Git的問題。而就在昨天,突然又收到了小明的消息。
也許你應該試試Git Flow
“鋒哥,我現在已經是Android Leader了。現在安卓團隊一共6個人,我們現在在做一個社交類應用,在Git管理方面我還是發現了一些問題。其中一個問題就是,現在版本庫有好多分支,其中開發主要在develop分支。主干分支是master主要用于版本發布??蛇€有一些分支卻顯得非?;靵y,有什么辦法改善這種情況嗎?”
“關于Git的分支設計,目前有一個公認比較好的設計叫 Git Flow模型。關于Git Flow模型,你可以查看這篇文章 http://nvie.com/posts/a-successful-git-branching-model/ 了解一下"
一個idea,一次提交
"好的!還有一個困擾了我很久的問題是,大家的提交日志寫的比較籠統。在查找問題的時候非常不便,而且大部分同學一次性提交好多文件,導致解決問題的時候不能準確定位到具體是哪一次提交導致的。我告訴大家,一次提交改動要盡可能小。但當別人問到具體的提交規則的時候我又不知道從何說起..."
“這是一個很好的問題 。中國程序員普遍存在的一個問題是,恨不得把這輩子能提交的代碼一次性搞定。甚至有人用多次提交太麻煩的借口來搪塞問責人。簡單來說,可以用一句話概括提交原則:一個idea,一次提交。另外,你說的沒錯,提交必須盡可能小,注釋必須盡可能表述準確!”
給小明講了這么多Git,我忍不住半開玩笑地問他,“小明,你現在還覺得Git簡單嗎?”
小明發了一個無奈的表情!說道,“以前是我才疏學淺,略知皮毛,不知道Git原來還有這么多玩法,忍不住為Git的發明者點贊了。對了,鋒哥,Git到底是誰開發的?”
Git的最大功臣,其實不是Linus
”關于Git的故事,互聯網上其實已經爛大街了。我簡單給你介紹一下吧!Git的誕生其實是一個偶然,其初始使命是為Linux內核代碼管理服務的。早年的時候Linux內核源碼是用Bitkeeper版本控制工具管理的??墒牵髞硪驗槟承├骊P系,Bitkeeper要求Linux社區付費使用。這一舉動激怒了Linus,也就是Linux的創始人,他決定自己開發一個分布式版本控制系統。幾周時間下來,Git的雛形就誕生了,并且開始在Linux社區中應用開來。雖然Linus是Git的創始人,可是背后的最大功臣卻是一個日本人 Junio C Hamano。Linus在Git開源版本庫的提交只有258次,而Junio C Hamano卻提交了4000多次。也就是說,在Linus開發后不久項目的管理權就交給了這個日本人。關于 Junio C Hamano,你感興趣的話可以Google了解一下。他現在在Google工作,如同Linus一樣非常低調。“
“這個故事也告訴我:不要用技術去挑戰一個程序員 @_@ ”
這個故事講完,小明與Git的故事就已經告一段落了。其實,還有一些比較常見的問題,小明并沒有問到過。這里,我為你準備了一個附錄,給你介紹一些常用的小命令幫你解決日常小問題。它很有用,一定要拿筆記下來,或者收藏這篇文章備用。
常見問題
問題一:公司的Git服務器是搭建在一個內網服務器上面的,我想把代碼同時提交到OsChina上面,以便在家拉取代碼,遠程辦公,怎么辦?
Git本身是一個分布式的版本管理系統,實現這個需求非常簡單,使用git remote add
命令添加多個遠程版本庫關聯即可。
git remote add company git@xxx
git remote add home git@xxx
問題二:在拉取遠程代碼的時候,如果本地有代碼還沒有提交,Git就會提示先提交代碼到版本庫??蓵簳r我又不想提交,怎么辦?
針對這個問題,Git提供了一個臨時區域用于保存不想提交的記錄,對應的命令是git stash
。通常情況下,你可以這樣操作:
# 將暫時還不想提交的數據保存到臨時區域,保存成功后,工作區將和版本庫完全一致
git stash
# 還原stash數據到工作區
git stash apply
# 以上操作完成后,stash數據依然保存在臨時區域中,為了刪除這部分數據,使用如下命令即可。
git stash drop
# 如果你想在還原數據的同時從臨時區域刪除數據,可以這樣操作:
git statsh pop
# 以上兩個命令如果不接任何參數將刪除掉所有的臨時區域數據,如果你只想刪除其中一條記錄,指定對應索引數據即可。
git stash pop/drop stash@{index}
# 查看臨時區域所有數據,使用如下命令:
git stash list
問題三:作為項目負責人,我希望迅速找出問題代碼的“元兇”,有什么辦法嗎?
針對這個問題,最好的答案是git blame
,使用這個命令并指定具體文件它將顯示文件每一行代碼的最近修改記錄,你可以清晰地看到最近代碼的修改人。
問題四:部分Team Leader會要求使用git rebase
合并代碼,這有什么好處嗎?
我們用一個簡單的思維來理解這個問題,最常見的合并操作是使用git merge
,而這樣操作會在合并分支生成一次新的提交,并且會嚴格記錄分支提交日志,在長期開發過程中,日志就會呈現多條線路展示,給閱讀帶來一定的障礙。而使用git rebase
會使整體代碼提交記錄始終像在單一分支開發一樣,僅使用一條線路展示。但使用git rebase
是有一定陷阱的,這個問題需要一定的時間才能說清楚,如果需要了解兩個命令的詳細區別,我推薦你閱讀這篇文章 Rebase 代替合并。
總結
Git是一個非常優秀的版本控制系統,我極力推薦你在日常開發中使用。這篇文章從小明的角度解釋了幾個常見問題的解決方案,毫無懸念地,你可能還會遇到其它的一些問題。遇到問題,你可以嘗試使用Google搜索解決方案;也可以在文章下方給我留言,我非常樂意為你解答Git問題。
我是歐陽鋒,版本控制,我使用Git。了解歐陽鋒,從這里開始:歐陽鋒檔案館。