第 5.3 章 分支與 Tag
分支
分支的本質(zhì)
分支可以簡單的理解為一個指針,指向某個提交。而每個提交都記錄了它的父提交,從而形成了一個鏈表。當(dāng)某個分支上不斷產(chǎn)生提交時,分支指向的提交就會發(fā)生改變,不斷向后移動,相當(dāng)于這個鏈表在不斷延長。
查看分支
輸入 gb,它會展示所有本地分支,等價于命令 git branch, 輸入命令 gbv 可以額外顯示每個分支的最后一次提交和這個分支跟蹤的遠(yuǎn)程分支,等價于命令 git branch -vv
輸入 gb branch_name 表示創(chuàng)建一個分支,指向當(dāng)前提交,gb branch_name commit 表示新建一個分支并指向某個 commit,注意這兩個命令都不會切換分支。
輸入 gba,查看本地和遠(yuǎn)程分支,等價于命令 git branch -a,輸入命令 gbr,查看遠(yuǎn)程分支,等價于命令 git branch --remote
刪除分支
輸入 gbd branch_name 刪除某個分支,等價于命令 git branch -d
并非所有分支都可以通過 gbd 命令刪除,可以通過 git branch --merged 來查看已經(jīng)合入到某個指針\(默認(rèn)是 HEAD\)的分支,換句話說是可以通過這個指針回溯到的分支。這個命令也簡寫為 gbm
gbm 列出的分支都是可以抵達(dá)的,因此可用 gbd 刪除,而 gbnm 列出的則是不可達(dá)的分支,因此不能用 gbd 刪除,它是 git branch --no-merged 命令的簡寫
如果真的要強(qiáng)行刪除,可以用 gbD 命令,它是 git branch -D 的簡寫
切換分支
輸入 gco branch 可以 checkout 到某個分支上,等價于命令 git checkout,注意如果有未提交的改動,請不要切換分支。
輸入 gcb new_branch 可以創(chuàng)建分支 new\_branch 并切換到這個分支上,它是 git checkout -b 命令的縮寫,等價于 git branch new_branch 和 git checkout new_branch 這兩條命令
有時候我們要從遠(yuǎn)程倉庫檢出一個新的分支,比如叫 feature 吧,有幾種思路:
git branch -t feature origin/feature
git checkout -b feature origin/feature
git checkout --track origin/feature
第一種寫法不太合適,因為它只會創(chuàng)建分支并且跟蹤遠(yuǎn)程分支,并不會切換。我想一般創(chuàng)建分支的時候都是需要切換的,否則你創(chuàng)建它干嘛呢,可以等到要切換的時候再創(chuàng)建吶。
第二種寫法稍微高級些,它和第一種寫法一致并且可以切換分支,之前的 tips 中介紹過 gcb 后面加單個參數(shù)的含義和用法,這里第二個參數(shù)表示跟著遠(yuǎn)程分支。
第三中方法最簡單,因為它參數(shù)少,而且功能和第二種寫法一樣,我給他起的別名叫 gct,對應(yīng) git checkout --track
如果想為當(dāng)前分支設(shè)置跟蹤的遠(yuǎn)程分支,輸入 gtrack 即可,不需要攜帶參數(shù),它會自動讓當(dāng)前分支跟蹤遠(yuǎn)程的同名分支
Tag
標(biāo)簽是分支功能的子集,可以理解為不能移動的分支。前文說過,分支始終指向某個鏈表的開端,是可以移動的。但 Tag 始終指向某個固定的提交,不會移動。
使用 gco 同樣可以切換到某個分支上。其他常用命令如下:
使用命令 gt tag_name 可以打 tag,它是 git tag 命令的縮寫。
使用命令 gtd tag_name 可以刪除本地 tag,它是 git tag -d 命令的縮寫
第 5.4 章 代碼修改
Stash
這個命令用來儲藏當(dāng)前未提交的改動,我配置了兩個別名:
gst:用來儲藏改動,是 git stash -u 的縮寫,-u 參數(shù)表示未跟蹤的文件\(untracked\) 也會被儲藏。
gsp :用來復(fù)原儲藏,是 git stash pop --index 的縮寫,--index 表示會試圖還原此前的索引狀態(tài)。比如原來改動了兩個文件 a 和 b,其中文件 a 已經(jīng)被添加到暫存區(qū)(add)但 b 沒有。普通的 git stash pop 會把 a 和 b 都還原為未暫存狀態(tài)。添加 --index 參數(shù)則會將 a 還原為暫存狀態(tài)。
Reset
很多人可能大概知道 reset 有三種模式,很多文章上來就開始介紹這三種模式的異同,在我看來這不是一種很好的教學(xué)方式。對于不是特別了解 Git 模型的讀者來說,有必要介紹一些基礎(chǔ)知識。
首先,在 Git 的思維中,它會管理三塊不同的區(qū)域,工作區(qū)、暫存區(qū)和歷史區(qū)。假設(shè)我們 clone
下來一個新的項目,此時三個區(qū)域內(nèi)的文件內(nèi)容是一模一樣的。此時如果輸入 git status命令會沒有任何輸出。注意,這個命令并不會記錄改動,而是時刻比較 工作區(qū)和暫存區(qū) 以及 暫存區(qū)和歷史區(qū) 之間的差異,從而得出待暫存和待提交的文件列表。
當(dāng)我們開始寫代碼時,文件發(fā)生了變動,這其實是更改了工作區(qū)中的代碼,但暫存區(qū)和歷史區(qū)是一致的,因此此時 git status 會顯示有一些文件需要暫存,但不會提示有文件需要提交。使用
git add 命令后,它會把改動的文件和要提交的部分拷貝到暫存區(qū)中,此時工作區(qū)和暫存區(qū)是一致的,但暫存區(qū)和歷史區(qū)的文件不一樣,所以不會再顯示有文件需要暫存,只會提示有文件要提交。輸入 git commit 以后三個區(qū)域內(nèi)的內(nèi)容又保持一致了,因此 git status 不會再有任何輸出。
在閱讀 reset 的用法前,請務(wù)必確保你真的讀懂了之前的兩段話,否則請讀到明白為止。
首先,reset 有兩種用法,它的第一個參數(shù)是提交的 SHA-1,第二個參數(shù)如果不寫則是整體重置,否則只重置單個文件,我們先介紹整體重置的情況。
此時,HEAD 指針一定會移動到指定的那次提交上,也就是說歷史區(qū)會與指定的那次提交保持一致。如果是用 reset --soft 參數(shù),那么重置就到此為止了。由于只有歷史區(qū)被重置了,暫存區(qū)還沒有發(fā)生變化,所以這個命令的作用相當(dāng)于撤銷了 commit,并且把他們都放入暫存區(qū)。
接下來,reset 命令會試圖把暫存區(qū)也和指定的提交保持一致。因此重置完成后,暫存區(qū)和歷史區(qū)保持一致,但工作區(qū)和暫存區(qū)會出現(xiàn)大量的不一致,所以 git status 命令會提示我們有很多文件需要暫存(add)。如果是用 reset --mixed 參數(shù)或者不加任何參數(shù),重置就會到此為止。可見這個命令相當(dāng)于撤銷了 git add 和 git commit 操作。
最后,reset 命令還有一個參數(shù)是 --hard,它會試圖把工作區(qū)也和指定的提交保持一致。這個命令是不安全的,如果工作區(qū)內(nèi)的文件還沒有提交,它就會丟失。提交過的文件可以用 git reflog 找回。重置結(jié)束后,三個區(qū)域內(nèi)的文件都和指定的提交保持一致,git status 不會有任何輸出。
總結(jié)一下,git reset 在重置版本時會做三件事:
讓歷史區(qū)與指定的提交保持一致,如果是 --soft參數(shù)則到此為止
讓暫存區(qū)和歷史區(qū)與指定的提交保持一致,如果不加參數(shù)或者是 --mixed 參數(shù)則到此為止
讓工作區(qū)、暫存區(qū)和歷史區(qū)都與指定的提交保持一致。如果是 --hard 參數(shù)就會走到這一步
注意,我們要記的是 reset 命令的本質(zhì),而不是它的外在表現(xiàn)。理解了它會做什么,就很容易預(yù)測這么做的結(jié)果
至于 reset 的另一個用法:重置文件,和上述規(guī)則類似,只是它不會改變歷史區(qū),自然也就不存在 --soft 參數(shù),其他兩個參數(shù)的用法和規(guī)則是完全一致的,只不過是對文件生效。
在實際使用中,我配置了這三個命令:
grh:讓工作區(qū)、暫存區(qū)和歷史區(qū)都與指定的提交保持一致,可以理解為撤銷所有改動,是命令 git reset --hard 的簡寫
grm:讓暫存區(qū)和歷史區(qū)與指定的提交保持一致,可以理解為撤銷 git add,是命令 git reset的縮寫,通常我會用 grm file_name 來撤銷對某個文件的暫存
grs:讓歷史區(qū)與指定的提交保持一致,可以理解為撤銷 git commit,是命令 git reset --soft 的縮寫
Checkout
除了可以在分支和 tag 間進(jìn)行切換外,如果 checkout 后面加上文件名,可以將尚未暫存的文件重置為初始狀態(tài)。
因此,這個命令也可以理解為僅對工作區(qū)生效的 git reset --hard,這是一個不可挽回的操作,請謹(jǐn)慎執(zhí)行。
第 5.5 章 代碼同步
Fetch
fetch 是最簡單的操作,它將遠(yuǎn)程倉庫的代碼、 分支和 tag 都下載到本地。有些 GUI 工具會定期自動執(zhí)行 fetch。
注意,如果僅僅是 fetch 代碼,并不會改變本地的代碼,僅僅是預(yù)先下載了遠(yuǎn)程倉庫的變動而已。直到我們進(jìn)行 rebase、merge、checkout、reset 等操作時,才會改動本地代碼。
Rebase or Merge
rebase 和 merge 分別是合并代碼的兩種操作。
rebase 是變基,也就是改變某次提交的父提交。假設(shè)當(dāng)前處于分支 branch_a 并執(zhí)行:
git rebase branch_b
本質(zhì)上是找到 branch_a 和 branch_b的公共祖先,然后將 branch_a 到這個公共祖先中的每一次提交,依次變基到 branch_b 上。
同樣是上面的情況,使用merge :
git merge branch_a
則會將 branch_a 到公共祖先之間的提交壓縮成一次新的提交,附加在 branch_b 的后面。
關(guān)于選擇 merge 還是 rebase,一般沒有明確的要求。前者忠實的保留了每次提交的真實情況,但是多人開發(fā)時頻繁 merge 容易導(dǎo)致時間線爆炸,影響閱讀。rebase 的優(yōu)點(diǎn)在于它創(chuàng)造出更加優(yōu)雅的提交記錄,缺點(diǎn)則是破壞了真實的提交記錄。
但假設(shè)我們有一個主分支 dev,還有一個開發(fā)分支 feature ,兩者都提交了上百次代碼,現(xiàn)在想把開發(fā)分支合入主分支,那么大概率應(yīng)該使用 merge。一方面這樣的合并次數(shù)很少,不會造成時間線爆炸,反倒是真實的保留了 feature 分支的提交記錄,最重要的是如此龐大的兩個分支合并一定會帶來大量沖突。 merge 會自動把所有提交壓縮成一個,只要解決一次沖突就行,但 rebase 的次數(shù)將會達(dá)到上百次,會出現(xiàn)大量不必要的沖突。
舉個很簡單的例子,假設(shè)提交 a 和 b 是兩個互逆的操作,那么在 merge 就互相抵消了,但如果使用 rebase,就需要解決兩次沖突。
但如果只是想從遠(yuǎn)程倉庫獲取代碼,并且更新本地的代碼,此時就更推薦 rebase 了,我配置了快捷鍵 gsfrs,它的完整定義是:
alias gsfrs='git stash;git fetch;git rebase;git stash pop;'
交互式 Rebase
對于已經(jīng)存在但還沒有推送到遠(yuǎn)程的提交記錄,我們可以使用 rebase -i 去編輯他們。假設(shè)我們想修改最近三次提交,可以輸入 gri head~3,它是完整寫法是:
git rebase -i head~3
這個命令會展示出最近的三次提交,最老的提交在最上面,最新的提交在最下面,這是因為 git 會按照從舊到新的順序編輯這些提交。展示的格式如下:
pick commit_id commit_message
我們可以隨意調(diào)整這三行的順序,相當(dāng)于改變提交的順序。如果把單詞 pick 改成 reword 或 r,就可以修改提交記錄。
git 還支持以下關(guān)鍵字:
edit 或 e:編輯此次提交
drop 或 d:刪除此次提交
fix 或 f:將此次提交與上次提交合并
Pull
pull 可以理解為一個語法糖,因為它等價于 fetch + merge,前文已經(jīng)說過日常開發(fā)時并不推薦使用 merge,只有在偶爾分支合并時才應(yīng)該使用,因此 pull 也應(yīng)該慎用。
Commit
前文說過通過交互式 rebase 可以修改歷史提交記錄,但如果只想修改上一次提交的信息,可以使用更簡單的 gca 命令,它的完整寫法是:
git commit --amend
然后編輯 commit_message 并退出即可。
我們都知道提交代碼前,需要先將改動的文件暫存,然后再提交。但如果我們想提交所有未暫存的文件,其實還有更快速的方法:gcam,它的完整寫法是:
git commit --all -m
# 實際上等價于
git add . && git commit -m
第 5.6 章 解決沖突
構(gòu)造沖突
本文主要通過一個簡單的 Demo 來演示如何在 Git 中解決沖突,以及相關(guān)名詞的基本概念。
首先我們編輯一個名為 begin 的文件:
This is first line
然后在分支 a 上增加兩行,用加號標(biāo)記出來:
This is first line
+This is 2nd line
+This is 3rd line
再在分支 b 上增加兩行,用加號標(biāo)記出來:
This is first line
+This is second line
+This is third line
當(dāng)我們想把分支 a 變基(rebase)到分支 b 上時,沖突必然會出現(xiàn),因為兩個分支修改了相同的行,git 不知道怎么處理了。此時有如下輸出:
可以看到文件被標(biāo)記為了 UU。U 的意思表示 updated but unmerged,兩個 U 則是說明兩個分支都做了修改,但還沒有合并。出現(xiàn) UU 基本上都意味著發(fā)生了沖突。
放棄合并
如果對解決方式?jīng)]有信心,可以暫時先放棄合并,輸入 git rebase --abort 即可。如果當(dāng)初選擇的是 merge 兩個分支,那么將是 git merge --abort。
這個命令能回退到合并分支前,但它無法在正確處理工作目錄中的變動。也就是說,在開始合并之前,務(wù)必確保自己的工作目錄是干凈的。
不過在相當(dāng)多的場合,Git 會自動做出提醒。比如當(dāng)你的工作目錄有改動時,直接就無法 rebase:
如果 merge 會影響到工作目錄的改動,Git 也會禁止你 merge,比如我隨便修改 begin 的最后一行,再執(zhí)行 merge 操作會得到如下錯誤:
所以請牢記第一點(diǎn): 在 rebase 或者 merge 之前,務(wù)必確保你的工作目錄是干凈的
沖突描述
我們來看一下沖突的文件長什么樣,注意我們是在分支 a 上 rebase 到分支 b:
可以看到兩個提交之間用 ===== 來分割,上面的部分有 <<<< 這個標(biāo)記,后面跟著一串 SHA1 值,它其實就是分支 b 指向的那次提交。
下面的部分寫得很明確,是分支 a 指向的那次提交內(nèi)容。
如果我們在分支 b 上使用 git merga branch_a,得到的效果將會是:
可見,除了對分支 b 的描述不太一樣以外,沖突的內(nèi)容是一樣的。都是上面是 b 分支的改動,下面是 a 分支的改動。
于是得出第二個結(jié)論:沖突被多個等號分割為兩部分,上面是當(dāng)前的改動,而下面是將要合入的改動。
沖突文件的原理
運(yùn)行命令 git ls-files -u,其中 -u 參數(shù)用來展示還沒有合并的改動:
可見當(dāng)前存在 3 份 begin 文件,我們可以這樣查看第一個文件:
git show :1:begin
第二個文件:
git show :2:begin
這個文件又被稱為 ours,如果以這個文件的改動為準(zhǔn),將會得到等號上面的那部分結(jié)果。可以用 git diff --ours 來驗證。下圖表示當(dāng)前沖突相對于 ours 的變化(多了下面的那部分)。
第三個文件:
git show :3:begin
這個文件又被稱為 theirs,如果以這個文件的改動為準(zhǔn),將會得到等號下面的那部分結(jié)果,可以用 git diff --theirs 來驗證。下圖表示當(dāng)前沖突相對于 theirs 的變化(多了上面的那部分)。
于是得出第三個結(jié)論:“我們的”指的是已有的改動,也就是等號上面部分的改動,“他們的”則是將要合入的改動,也就是等號下面部分的改動。
比如將在分支 b 上執(zhí)行 merge a 或者在分支 a 上執(zhí)行 rebase b,此時分支 a 的改動被叫做 ours,而分支 b 的改動則被稱為 theirs。
我個人建議這樣記憶:已有的分支是原住民,也就是“我們的”,將要合入的代碼是入侵者,也就是 “他們的”。
解決沖突
最簡單的方式就是只是用某一方的改動來解決沖突,比如我認(rèn)為分支 a 的改動是無效的,分支 b 的改動才是合理的,也就以 “我們的(ours)” 改動為準(zhǔn),忽略將要合入的代碼,可以執(zhí)行命令:
git checkout --ours begin
git add begin
git commit
# 注意不用加 -m 選項,git 會默認(rèn)生成一個 merge 的 message
對應(yīng)到之前的 diff 輸出中來,如果你只想保留等號上面的部分,可以用 --ours 參數(shù),否則使用 --theirs 參數(shù)。
撤銷合并
先討論 merge 的情況,此時分之關(guān)系如圖所示:
如果代碼還沒有推送到遠(yuǎn)程倉庫,只要 reset --hard 到上一次提交即可:
git reset --hard HEAD~
此時效果如圖所示:
如果已經(jīng)推送到遠(yuǎn)程倉庫,這樣 reset 就不行了,此時可以用 revert 命令。我將在后續(xù)的文章中做詳細(xì)的介紹。
第 5.7 章 Git 核心原理
假設(shè)我們有兩個分支,a 和 b,它們的提交都有一個相同的父提交(master 指向的那次提交)。如圖所示:
現(xiàn)在我們在分支 b 上,然后 rabase 到分支 a 上。如圖所示:
平時開發(fā)中經(jīng)常遇到這種情況,假設(shè)分支 a 和 b 是兩個獨(dú)立的 feature 分支,但是不小心被我們錯誤的 rebase 了。現(xiàn)在相當(dāng)于兩個 feature 分支中原本獨(dú)立的業(yè)務(wù)被揉起來了,當(dāng)然是我們不想看到的結(jié)果,那么如何撤銷呢?
一種方案是利用 reflog 命令。
利用 reflog 撤銷變基
我們先不考慮原理,直接上解決方案,首先輸入 git reflog,你會看到如下圖所示的日志:
最后的輸出其實是最早的操作,我們逐條分析下:
HEAD@{8}: 這里我們創(chuàng)建了初始的提交
HEAD@{7}:檢出了分支 a
HEAD@{6}:在分支 a 上做了一次提交,注意 master 分支沒有變動
HEAD@{5}:從分支 a 回到分支 master,相當(dāng)于向后退了一次
HEAD@{4}:檢出了分支 b
HEAD@{3}:在分支 b 上做了一次提交,注意 master 分支沒有變動
HEAD@{2}:這一步開始變基到分支 a,首先切換到分支 a 上
HEAD@{1}:把分支 b 對應(yīng)的那次提交變基到分支 a 上
HEAD@{0}:變基結(jié)束,因為是在 b 上發(fā)起的變基,所以最后還切回分支 b
如果我們想撤銷此次 rebase,只要輸入以下命令就可以了:
git reset --hard HEAD@{3}
此時再看,已經(jīng)“恢復(fù)”到 rebase 前的狀態(tài)了。的是不是感覺很神奇呢,先別著急,后面會介紹這么做的原理。
git 工作原理簡介 {#sectiongit}
為了搞懂 git 是如何工作的,以及這些命令背后的原理,我想有必要對 git 的模型有基礎(chǔ)的了解。
首先,每一個 git 目錄都有一個名為 .git 的隱藏目錄,關(guān)于 git 的一切都存儲于這個目錄里面(全局配置除外)。這個目錄里面有一些子目錄和文件,文件其實不重要,都是一些配置信息,后面會介紹其中的 HEAD 文件。子目錄有以下幾個:
info:這個目錄不重要,里面有一個 exclude 文件和 .gitignore 文件的作用相似,區(qū)別是這個文件不會被納入版本控制,所以可以做一些個人配置。
hooks:這個目錄很容易理解, 主要用來放一些 git 鉤子,在指定任務(wù)觸發(fā)前后做一些自定義的配置,這是另外一個單獨(dú)的話題,本文不會具體介紹。
objects:用于存放所有 git 中的對象,下面單獨(dú)介紹。
logs:用于記錄各個分支的移動情況,下面單獨(dú)介紹。
refs:用于記錄所有的引用,下面單獨(dú)介紹。
本文主要會介紹后面三個文件夾的作用。
git 對象
git 是面向?qū)ο蟮模?br> git 是面向?qū)ο蟮模?br> git 是面向?qū)ο蟮模?/p>
沒錯,git 是面向?qū)ο蟮模液芏鄸|西都是對象。我舉個簡單的例子,來幫助大家理解這個概念。假設(shè)我們在一個空倉庫里,編輯了 2 個文件,然后提交。此時都會有那些對象呢?
首先會有兩個數(shù)據(jù)對象,每個文件都對應(yīng)一個數(shù)據(jù)對象。當(dāng)文件被修改時,即使是新增了一個字母,也會生成一個新的數(shù)據(jù)對象。
其次,會有一個樹對象用來維護(hù)一系列的數(shù)據(jù)對象,叫樹對象的原因是它持有的不僅可以是數(shù)據(jù)對象,還可以是另一個樹對象。比如某次提交了兩個文件和一個文件夾,那么樹對象里面就有三個對象,兩個是數(shù)據(jù)對象,文件夾則用另一個樹對象表示。這樣遞歸下去就可以表示任意層次的文件了。
最后則是提交對象,每個提交對象都有一個樹對象,用來表示某一次提交所涉及的文件。除此以外,每一個提交還有自己的父提交,指向上一次提交的對象。當(dāng)然,提交對象還會包含提交時間、提交者姓名、郵箱等輔助信息,就不多說了。
假設(shè)我們只有一個分支,以上知識點(diǎn)就足夠解釋 git 的提交歷史是如何計算的了。它并不存儲完整的提交歷史,而是通過父提交的對象不斷向前查找,得出完整的歷史。
注意開頭那張圖片,分支 b 指向的提交是 9cbb015,不妨來看下它是何方神圣:
git cat-file -t 9cbb015
git cat-file -p 9cbb015
這里我們使用 cat-file 命令,其中 -t 參數(shù)打印對象的類型,-p 參數(shù)會智能識別類型,并打印其中的內(nèi)容。輸出結(jié)果如圖所示:
可見 9cbb015 是一個提交對象,里面包含了樹對象、父提交對象和各種配置信息。我們可以再打印樹對象看看:
這表示本次提交只修改了 begin 這個文件,并且輸出了 begin 這個文件對于的數(shù)據(jù)對象。
git 引用
既然 git 是面向?qū)ο蟮模敲从袥]有指針呢?還真是有的,分支和標(biāo)簽都是指向提交對象的指針。這一點(diǎn)可以驗證:
cat .git/refs/heads/a
所有的本地分支都存儲在 git/refs/heads 目錄下,每一個分支對應(yīng)一個文件,文件的內(nèi)容如圖所示:
可見,4a3a88d 剛好是本文第一張圖中分支 a 所指向的提交。
我們已經(jīng)搞明白了 git 分支的秘密,現(xiàn)在有了所有分支的記錄,又有了每次提交的父提交對象,就能夠得出像 SourceTree 或者文章開頭第一張圖那樣的提交狀態(tài)了。
至于標(biāo)簽,它其實也是一種引用,可以理解為不能移動的分支。只能永遠(yuǎn)指向某個固定的提交。
最后一個比較特殊的引用是 HEAD,它可以理解為指針的指針,為了證明這一點(diǎn),我們看看 .git/HEAD 文件:
它的內(nèi)容記錄了當(dāng)前指向哪個分支,refs/heads/b 其實是一個文件,這個文件的內(nèi)容是分支 b 指向的那個提交對象。理解這一點(diǎn)非常重要,否則你會無法理解 checkout 和 reset的區(qū)別。
這兩個命令都會改變 HEAD 的指向,區(qū)別是 checkout 不改變 HEAD 指向的分支的指向,而 reset 會。舉個例子, 在分支 b 上執(zhí)行以下兩個命令都會讓 HEAD 指向 4a3a88d 這次提交(分支 a 指向的提交):
git checkout a
git reset --hard a
但 checkout 僅改變 HEAD 的指向,不會改變分支 b 的指向。而 reset 不僅會改變 HEAD 的指向,還因為 HEAD 指向分支 b,就把 b 也指向 4a3a88d 這次提交。
git 日志
在 .git/logs 目錄中,有一個文件夾和一個 HEAD 文件,每當(dāng) HEAD 引用改變了指向的位置,就會在 .git/logs/HEAD 中添加了一個記錄。而 .git/logs/refs/heads 這個目錄中則有多個文件,每個文件對應(yīng)一個分支,記錄了這個分支 的指向位置發(fā)生改變的情況。
當(dāng)我們執(zhí)行 git reflog 的時候,其實就是讀取了 .git/logs/HEAD 這個文件。
撤銷 rebase 的原理
首先我們要排除一個誤區(qū),那就是 git 會維護(hù)每次提交的提交對象、樹對象和數(shù)據(jù)對象,但并不會維護(hù)每次提交時,各個分支的指向。在介紹分支的那一節(jié)中我們已經(jīng)看到,分支僅僅是一個保留了提交對象的文件而已,并不記錄歷史信息。即使在上一節(jié)中,我們知道分支的變化信息會被記錄下來,但也不會和某個提交對象綁定。
也就是說,git 中并不存在某次提交時的分支快照
那么我們是如何通過 reset 來撤銷 rebase 的呢,這里還要澄清另一個事實。前文曾經(jīng)說過,某個時刻下你通過 SourceTree 或者 git log 看到的分支狀態(tài),其實是由所有分支的列表、每個分支所指向的提交,和每個提交的父提交共同繪制出來的。
首先 git/refs/heads 下的文件告訴我們有多少分支,每個文件的內(nèi)容告訴我們這個分支指向那個提交,有了這個提交不斷向前追溯就繪制出了這個分支的提交歷史。所有分子的提交歷史也就組成了我們看到的狀態(tài)。
但我們要明確:不是所有提交對象都能看到的,舉個例子如果我們把某個分支向前移一次提交,那個分支的提交線就會少一個節(jié)點(diǎn),如果沒有別的提交線包含這個節(jié)點(diǎn),這個節(jié)點(diǎn)就看不到了。
所以在 rebase 完成后,我們以為看到了下面這樣的提交線:
df0f2c5(master) --- 4a3a88d(a) --- 9cbb015(b)
實際上是這樣的:
df0f2c5(master) --- 4a3a88d(a) --- 9d0618e(b)
|
9cbb015
master 分支上依然有分叉,原來 9cbb015 這次提交依然存在,只不過沒有分支的提交線包含它,所以無法看到而已。但是通過 reflog,我們可以找回 HEAD 頭的每一次移動,所以能看到這次提交。
當(dāng)我們執(zhí)行這個命令時:
git reset --hard HEAD@{3}
再看一次 reflog 的輸出:
HEAD@{3} 其實是它左側(cè) 9cbb015 這次提交的縮寫,所以上述命令等價于:
git reset --hard 9cbb015
前文說過,reset 不僅會移動 HEAD,還會移動 HEAD 所指向的分支,所以這個命令的執(zhí)行結(jié)果就是讓 HEAD 和分支 b 同時指向 9cbb015 這個提交,看起來像是撤銷了 rebase。
但別忘了,分支 a 的上面還是有一次提交的,9d0618e 這次提交僅僅是沒有分支指向它,所以不顯示而已。但它真實的存在著,嚴(yán)格意義上來說,我們并沒有真正的撤銷此次 rebase。
第 6 章 終極武器 Zsh
Shell 是一個非常龐大的話題,它的學(xué)習(xí)路線和普通的編程語言不一致,使用場景在很多人看了也不多。但其實 Shell 是非常強(qiáng)大的膠水語言,能把其它各個模塊和系統(tǒng)很好的串聯(lián)起來,同時由于 shell 非常底層,更加接近操作系統(tǒng),所以非常用來和系統(tǒng)的軟硬件生態(tài)打交道。
與單純的研究 shell 語法,和系統(tǒng)、運(yùn)維開發(fā)不同的是,我更多的是希望降低 shell 的學(xué)習(xí)門檻,堅持實用主義,把那些能夠提高日常開發(fā)效率的知識介紹給讀者。在掌握這些基本原理后,讀者就可以根據(jù)自己的實際情況進(jìn)行定制了。本章主要分為三個階段:
首先會介紹最簡單,但是最容易被忽視的,shell 腳本的基本概念和模型。如果不了解 shell 腳本是怎么被執(zhí)行的,雖然不影響使用,但在理解更深一層的概念時就會遇到困難。
接下來是 shell 的基本語法,因為 shell 的寫法比較多,同一個功能有多種方式完成,對新人不太友好。所以這里會把所有基本的語法都整理出來,然后就只剩下拼接組合的工作了。
最后則是最精彩的部分,利用這些 shell 知識和腳本,讓自己的電腦更加強(qiáng)大,簡單,易用。
shell 的學(xué)習(xí)是持之以恒的過程,希望讀完本章后,讀者能掌握基本的概念,在后續(xù)的使用過程中不斷發(fā)現(xiàn)痛點(diǎn),解決痛點(diǎn),提高自己的能力。
第 6.1 章 Shell 基本模型與運(yùn)行原理
除了在命令行中直接輸入命令,我們還可以把多個命令匯總在一起,放在腳本中,便于后期一起執(zhí)行。本節(jié)主要介紹如何執(zhí)行 Shell 腳本。這個問題看起來很簡單, 但如果不把其中原理想清楚,會導(dǎo)致后期理解上存在偏差,容易踩坑。
假設(shè)當(dāng)前目錄下有一個 test.sh文件,內(nèi)容如下:
cd ~/Downloads
一共有三類方式執(zhí)行它:
調(diào)用解釋器執(zhí)行
顧名思義,就是把腳本的路徑傳入解釋器中去執(zhí)行:
sh test.sh
# 或者
bash test.sh
這兩者略有區(qū)別,我沒有整理過完整的差異,但至少對于 echo 命令來說,以下命令在兩種解釋器下得到的結(jié)果是不一樣的:
echo -e "hello\nworld"
bash 會正確的將 \n 解釋為換行符,sh 則不能。個人建議統(tǒng)一使用 bash 即可
利用解釋器執(zhí)行 Shell 腳本,實際上是在當(dāng)前的 Shell 環(huán)境中啟動了一個子進(jìn)程去執(zhí)行。
直接輸入文件名運(yùn)行
下面這行命令同樣可以用來執(zhí)行腳本:
./test.sh
此時要求腳本文件必須是可執(zhí)行的,否則將會報錯:
zsh: permission denied: ./test.sh
解決方法是變更文件的權(quán)限:chmod +x test.sh 然后再執(zhí)行就可以了。這種運(yùn)行方式也是新建一個子 Shell 去執(zhí)行腳本。
有的讀者可能會問,系統(tǒng)怎么知道這是 Shell 腳本而不是其他語言呢?實際上并不是通過文件名后綴來區(qū)分的,可以舉個例子:
#! /usr/bin/python
print "11"
這里用 Python 語法寫了一個腳本,但是后綴名保存為 sh,如果我們直輸入名稱去執(zhí)行,一樣可以得到正確的結(jié)果:
./py.sh
# 輸出 11
這里的 #! 被稱為 shebang,用來指定使用什么解釋器去執(zhí)行腳本。因此,規(guī)則可以簡單概括如下:
如果直接寫明了解釋器,比如 sh xxx.sh,會以顯式指定的解釋器為準(zhǔn),shebang 不生效。
如果直接寫可執(zhí)行文件的名字,則以 shebang 指定的解釋器為準(zhǔn)。
如果沒有指定 shebang,默認(rèn)是 bash,不會參考文件名的后綴。
在當(dāng)前 Shell 運(yùn)行
與前兩種方式不同的是,我們還可以在當(dāng)前的 Shell 中執(zhí)行腳本:
. ./test.sh
# 或者
source ./test.sh
這種做法的好處在于,由于不涉及到 Shell 進(jìn)程的切換,所有變量和函數(shù)的定義都是相通的。比如我在 util.sh 里面定義了函數(shù):
function sayHello {
echo "hello, world"
}
使用方可以通過 source 命令來獲取調(diào)用函數(shù)的能力:
source util.sh
sayHello
通過 . 和 srouce 來調(diào)用腳本基本上是一致的,區(qū)別在于 source 的兼容性更好,因此更加推薦。
第 6.2 章 Shell 變量與基礎(chǔ)語法
Shell 變量
定義變量
定義變量時,千萬不要在等號的兩邊加空格,否則會報錯。正確的做法是:
a=1
echo $a
引用變量
正如上面代碼所示,用 $ 加上變量名就可以引用變量,有時候我們還會看到另一種寫法:
a=1
echo $a
# 下面這種寫法也是一樣的,而且更推薦
echo ${a}
一般來說,用大括號把變量名括起來是多此一舉,兩者作用相同。但如果我們要做字符串拼接,可以這樣寫:
a=hello
echo ${a}wrold # 輸出 helloworld
echo $awrold # 變量 awrold 并不存在
在 bash/zsh 的語法中,不需要使用專門的字符串拼接函數(shù),只要把兩個變量寫在一起即可。
引號
bash/zsh 中有單引號和雙引號之分,區(qū)別在于單引號中的內(nèi)容完全是字面量,甚至單引號中都無法使用轉(zhuǎn)義字符再打印出單引號。
雙引號中,如果遇到變量,將會自動轉(zhuǎn)換為變量的值。
echo "\"\"" # 輸出 ""
echo '\'\'' # 沒有輸出,因為單引號內(nèi)部都是字面量
a=1
echo "$a" # 輸出 1
echo '$a' # 輸出 $a,因為單引號不支持變量的展開
注意!!!在 bash 中,如果變量是一個字符串,而且字符串中含有空格,用雙引號括起來的字符串將會被解析為一個獨(dú)立的單詞,直接用 $ 則會被 bash 解析為多個參數(shù)。
假設(shè)當(dāng)前目錄下有個名叫 a b 的文件夾:
p="a b"
cd $p # bash: cd: a: No such file or directory
# 這是因為上述命令等價于 cd a b
# 字符串 "a b" 的值被拆開傳到 cd 命令中,但是 cd 只接收一個參數(shù),導(dǎo)致路徑錯誤
# 正確的做法如下
cd "$p"
# 等價于 cd "a b",路徑正確
再次強(qiáng)調(diào),這個問題只在 bash 中存在,如果我們平時在終端中使用 zsh,但是腳本用 bash 執(zhí)行,就會遇到這個問題。
*nix 系統(tǒng)下任何文件夾、文件名嚴(yán)禁留空格,嚴(yán)禁帶中文,利人利己!
變量作用域
默認(rèn)情況下,變量的作用域是當(dāng)前的 Shell,即使變量定義在函數(shù)中也是如此:
function t() {
temp=111
}
echo $temp # 沒有輸出,變量 temp 未定義
t # 調(diào)用函數(shù),函數(shù)內(nèi)部會定義變量
echo $temp # 輸出 111
因此函數(shù)內(nèi)部的變量要加上 local 關(guān)鍵字才不會污染全局作用域:
function t() {
local temp=111
}
t # 調(diào)用函數(shù)
echo $temp # 沒有輸出
環(huán)境變量
bash/zsh 中的變量分為普通變量和環(huán)境變量兩種,區(qū)別就在于,當(dāng)我們從當(dāng)前的 shell 中打開一個新的 subshell 時,環(huán)境變量會被 subshell 繼承,普通變量則不會。
a=1 # 定義一個普通變量 a
bash # 打開一個新的
echo $a # 沒有輸出,因為 subshell 只會繼承父 shell 的環(huán)境變量
我們可以驗證下變量 a 確實是普通變量,而不是環(huán)境變量:
# 單獨(dú)運(yùn)行 set 命令可以打印出所有普通變量
set | grep 'a=' # 能看到變量 a 的定義
# 單獨(dú)運(yùn)行 env 命令可以打印出所有的環(huán)境變量
env | grep 'a=' # 沒有輸出,說明變量 a 不是環(huán)境變量
因此,如果想讓某個變量的作用域延伸到 subshell 中,就需要把它定義為環(huán)境變量。有兩種寫法都可以做到:
export a=1
declare -x a=1
除了在定義時導(dǎo)出為環(huán)境變量,也可以把已經(jīng)定義過的普通變量導(dǎo)出為環(huán)境變量:
export a
declare -x a
默認(rèn)全局變量
PWD
記錄當(dāng)前所在的目錄,通過 echo $PWD 查看
OLDPWD
表示上一次所在的目錄,輸入減號 - 可以快速跳轉(zhuǎn)到上一次所在的目錄
特殊變量
bash/zsh 中還有一些約定好的只讀變量,它們的值在腳本的運(yùn)行過程中動態(tài)確定,常見的有:
$0:表示腳本名字,可能是相對路徑,當(dāng)我們執(zhí)行 bash a/b/c/d.sh 是,$0 的值是 a/b/c/d.sh
$1、$2、……、$10:用來表示參數(shù),$1 表示第一個參數(shù),以此類推
$#:表示參數(shù)個數(shù)
$?:表示上一個命令的執(zhí)行結(jié)果,0 表示正常結(jié)束,非 0 表示出現(xiàn)錯誤
第 2、3 條規(guī)則在函數(shù)內(nèi)部同樣適用。
基礎(chǔ)語法
條件判斷
shell 的判斷有兩種寫法,分別是 [ 和 [[,舉個例子,下面兩種寫法都是正確的:
abc="1"
if [[ $abc = "1" ]]; then
echo "equal"
fi
# 或者
if [ $abc = "1" ]; then
echo "equal"
fi
雖然兩者看起來很類似,但 [ 很早就有了,它的本質(zhì)是調(diào)用內(nèi)置的 test 指令,而 [[ 的誕生則相對晚的多,它是 bash/zsh 的語法。
在實際開發(fā)中,兩者的細(xì)節(jié)差異較大,對初學(xué)者非常不友好,我的建議是統(tǒng)一使用 [[。
shell 中的判斷可以分為數(shù)字比較、字符串比較和文件判斷等幾大類。
數(shù)字判斷
判斷兩個數(shù)字相等有三種寫法:單等號、雙等號或 -eq 關(guān)鍵字:
[[ $abc = 1 ]] && echo "yes" || echo "not"
[[ $abc == 1 ]] && echo "yes" || echo "not"
# 或者
[[ $abc -eq 1 ]] && echo "yes" || echo "not"
# 輸出結(jié)果都是 yes
不等號可以用 != 或 -ne 表示,大于號可以用 > 或者 -gt 來表示,小于號用 < 或者 -lt 表示。這幾個英文單詞不必記憶。
但 shell 不支持 “大于等于”、“小于等于”這些判斷,前者用 -ge 表示,后者用 -le 表示。
字符串判斷
字符串的判等和數(shù)字一致,不同的是可以判斷字符串是否為空:
str=""
# 未定義和長度為零的字符串都算空字符串
[[ -z $str ]] && echo "yes" || echo "not" # 輸出 yes
[[ -n $str ]] && echo "yes" || echo "not" # 輸出 not
字符串還支持模式匹配:
str="hello"
[[ $str == he* ]] && echo "yes" || echo "not"
# 模式匹配,以 he 開頭的單詞都能匹配,hello 滿足要求,所以輸出 yes
文件判斷
文件判斷有以下幾種:
if [[ -e file ]]判斷是否存在,不限制類型
if [[ -f file ]]判斷文件是否存在,必須是普通類型的文件,不能是文件夾
if [[ -d file ]]判斷文件夾是否存在,必須是文件夾,不能是文件
邏輯運(yùn)算符
其它語言的幾種邏輯運(yùn)算符可以正常使用:
[[ ! $str == h*lo || 1 = 1 ]] && echo "yes" || echo "not"
# 第一個判斷取反,結(jié)果為 false,但第二個判斷為 true,所以最終效果是輸出 yes
[[ $str == h*lo && 1 = 2 ]] && echo "yes" || echo "not"
# 第二個判斷為 false,所以輸出 not
if 語句
完成的 if 語句如下:
if [[ expression_1 ]]; then
echo "condition 1"
elif [[ expression_2 ]]; then
echo "condition 2"
else
echo "condition else"
fi
其中 elif 和 else 語句都是可省略的,因此最簡單的 if 語句是:
if [[ expression_1 ]]; then
echo "condition 1"
fi
循環(huán)
for 循環(huán)的語法和 if 比較類似:
for f in `ls`; do
echo $f
done
第 6.3 章 命令串聯(lián)
管道
管道是 shell 中最常用的概念之一,它允許不同腳本、命令之間互相傳遞數(shù)據(jù),舉一個最常見的例子:
ls | grep 'a'
默認(rèn)情況下,命令 ls 會把當(dāng)前目錄下的文件輸出到屏幕上,但如果通過管道符號 |,它就會把輸出結(jié)果傳遞給下一個命令。
命令 grep 恰好支持從管道中讀取數(shù)據(jù),因此上面這行腳本的含義實際上是在當(dāng)前目錄內(nèi)尋找名稱含有字母 a 的文件。
我們可以自己模擬一下:
function before {
echo 'output'
}
function after {
read in
echo "Read from pipiline: "${in}
}
before | after
# 輸出結(jié)果為:
# Read from pipiline: output
重定向
說到管道,就不得提提它的孿生兄弟:重定向,最簡單的使用場景就是把原本輸出到屏幕的內(nèi)容,重定向到文件中。
當(dāng)然,這只是重定向最簡單的用途,如果不了解背后的運(yùn)行原理,就會影響到后續(xù)的使用。
首先,*nix 系統(tǒng)中有三種特殊的文件描述符,其中 0 表示標(biāo)準(zhǔn)輸入,它一般指的是我們的鍵盤,1 表示標(biāo)準(zhǔn)輸出,2 表示錯誤輸出,它們一般都表示屏幕。所以 Shell 可以理解為一個盒子,它從 0(標(biāo)準(zhǔn)輸入,也就是鍵盤)讀取命令,沒有錯誤的話就輸出到 1(標(biāo)準(zhǔn)輸出),命令執(zhí)行錯誤的話輸出到 2(錯誤輸出),最終都會在屏幕上顯示出來。
舉一個例子,請看下面這行代碼:
ls exist.sh not_exist.sh 1>success 2>fail
這行代碼的意思首先是要展示兩個文件,假設(shè)一個文件存在,另一個文件不存在(從名字就能看出來了),這樣會產(chǎn)生一行標(biāo)準(zhǔn)輸出和一行錯誤輸出。1>success 的意思是把標(biāo)準(zhǔn)輸出重定向到 success 這個文件,類似的,2 > fail 表示把錯誤信息輸出到 fail 這個文件。
類似的語法還可以寫成:
ls exist.sh not_exist.sh >success 2>&1
這是因為如果 > 前面不加數(shù)字,默認(rèn)是標(biāo)準(zhǔn)輸出。而 2>&1 則表示讓錯誤輸出使用和標(biāo)準(zhǔn)輸出相同的重定向方式。因此這個命令等價于 ls exist.sh not_exist.sh 1>success 2>success。
從嚴(yán)格意義上講,使用 2>&1 的效率更高一些,因為它會復(fù)用標(biāo)準(zhǔn)輸出的管道。
過濾輸出
有了上述背景的積累,我們來看一個實際的問題。有時候在 Shell 腳本中我們只希望用到一個命令的功能, 但不希望它產(chǎn)生任何輸出,此時可以使用如下命令:
command > /dev/null 2>&1
這行命令表示把標(biāo)準(zhǔn)輸出和錯誤輸出都重定向到 /dev/null 文件,只是一個特定的文件,可以理解為\黑洞\。因為任何內(nèi)容都可以寫入這個文件,但對這個文件的讀取永遠(yuǎn)會返回 EOF,也就是輸入的任何內(nèi)容都會被拋棄掉。
上述命令還可以簡寫為 command &>/dev/null,沒有什么理由和解釋,只不過是 > /dev/null 2>&1 縮略寫法。
除了使用 > /dev/null 這種寫法,還可以使用 >&-,它不表示重定向,而是表示直接關(guān)閉某種輸出。自然屏幕上也就沒有任何內(nèi)容了。
更多類似的技巧請參考這篇文章:Difference between 2>&-, 2>/dev/null, |&, &>/dev/null and >/dev/null 2>&1
輸入重定向
如果要想拷貝某個文件中的內(nèi)容到剪貼板,笨的人打開文件按下 Command + A 和 Command + C,聰明一些的人會輸入下面這個命令:
cat file | pbcopy
這種寫法其實還可以再提高一下效率,因為它會讀取文件,然后把原本輸出到標(biāo)準(zhǔn)輸出(屏幕)的內(nèi)容通過管道轉(zhuǎn)到 pbcopy 這個命令上。
更高效、更直接的寫法如下:
pbcopy < file
這樣可以減少一次 IO 操作
函數(shù)返回值
在函數(shù)的結(jié)尾可以使用 return 關(guān)鍵字,然而需要注意的是,調(diào)用函數(shù)后的返回結(jié)果,并不是 return 的內(nèi)容,而是 echo 的內(nèi)容。至于 return 的內(nèi)容,則可以通過 $? 這個特殊變量來讀取。
function foo {
echo 'output'
return 1
}
a=`foo`
echo $? # 輸出 1
echo $a # 輸出 output
在 if 語句中,除了可以進(jìn)行普通的判斷外,還可以直接根據(jù)命令的執(zhí)行結(jié)果進(jìn)行判斷。此時讀取的依然是 return 的結(jié)果。
前文中提過,正常執(zhí)行的命令返回值是 0,對應(yīng)到 if 語句中則是 true 分支:
function foo {
return 1
}
if test ; then
echo "1"
else
echo "0"
fi
# 因為函數(shù)返回 1,表示執(zhí)行失敗,所以最終輸出 0
前面曾經(jīng)介紹過如何判斷當(dāng)前目錄下是否存在某個文件,放到 if 中就可以寫為:
if `ls | grep -q 'a'` ; then
echo "yes"
else
echo "no"
fi
這里雖然介紹的是函數(shù)返回值,但對整個腳本同樣適用
第 6.4 章 Shell 錯誤處理
本文前面部分內(nèi)容摘錄自阮一峰老師的:Bash 腳本 set 命令教程,主要是文章寫得太好了。
開啟錯誤處理
使用 shell 中的錯誤處理有助于我們發(fā)現(xiàn)錯誤,更好的調(diào)試代碼。
檢測未定義變量
首先,set -u 可以在遇到未定義變量時拋出錯誤,而不是忽略它。比如:
echo $bar
這里的變量 bar 沒有定義,shell 的默認(rèn)方案是忽略掉它。這就可能帶來隱藏的問題,所以通過 set -u 選項來強(qiáng)制報錯:
set -u
echo $bar
此時會得到報錯 ./test.sh: line 2: bar: unbound variable
報錯時退出
如果某個命令執(zhí)行錯了,可能會導(dǎo)致后續(xù)一系列命令執(zhí)行出錯。既不利于調(diào)試,也會導(dǎo)致很多意想不到的結(jié)果,所以可以用 set -e 選項來強(qiáng)制報錯時退出執(zhí)行腳本。
set -e
bbbb
ssss
如果不加上 set -e 會得到兩行報錯,因為 bbbb 和 ssss 都是不存在的指令。而加上以后,這里只會有一個報錯就立刻 exit 了。
需要注意的是,如果我們用管道的寫法,得到的返回值是最后一個命令的返回值,如果中間的命令出錯,是不能被 set -e 捕獲的,比如:
set -e
bs | ls
echo 'reach here'
得到的輸出結(jié)果將是:
aaa.sh: line 3: bs: command not found
test.sh
reach here
可見 bs 這個指令雖然不存在,但程序還是沒有退出,而是執(zhí)行到了結(jié)尾。因此 set -e 通常需要配合 set -o pipfail 來使用,這樣管道中的任何一個指令出錯,都會導(dǎo)致程序退出。
調(diào)試執(zhí)行
如果想知道每一行都執(zhí)行了什么代碼,可以用 set -x 選項,通常我們在 Jenkins 等工具里可以這么用,方便追查問題。比如:
set -x
ls
我們會得到:
+ ls
test.sh
以加號開頭的行就是文件的原始內(nèi)容了。
exit 鉤子
總結(jié)一下第一段的內(nèi)容,我們在任何 shell 腳本的開頭都應(yīng)該加上這行標(biāo)記:
set -euo pipefail
表示遇到錯誤指令或未定義的變量時立刻退出。當(dāng)然,如果需要調(diào)試,可以改成 set -euxo pipefail。
我們知道退出是靠 exit 命令來實現(xiàn)的,也就是說上述錯誤最終都會調(diào)用到 exit 命令,有沒有辦法捕獲這個退出呢?
最簡單做法當(dāng)然是封裝 exit,比如:
function bs_exit() {
echo "exit" && exit $1
}
但如果項目中已有大量的 exit,就需要我們手動替換。雖然成本能接受,但如果可以用 AOP 的方式來 hook exit 命令,肯定是最理想的。
這也是本文的重點(diǎn),經(jīng)過查閱資料,我們可以這樣寫:
function finish {
err=$?
if [[ $err == 1 ]]; then
echo '1'
fi
}
trap finish EXIT
這里的 trap 是一個內(nèi)置命令,用來捕捉發(fā)送給程序的信號。它接受兩個參數(shù),第一個是處理信號的方式,第二個則是信號名。
比如當(dāng)我們使用 exit 命令來退出腳本時,實際上是發(fā)送了 EXIT 信號,于是會被捕獲,并調(diào)用 finish 函數(shù)。函數(shù)內(nèi)部可以拿到 exit 后面的狀態(tài),因此可以區(qū)分用戶是通過 exit 1 還是 exit 2 來退出的,方便執(zhí)行對應(yīng)的操作。
這種寫法的另一個好處在于它是全局的,比如當(dāng)我的 shell 腳本存在嵌套調(diào)用關(guān)系時,只要在入口處定義一次就好,它可以自動捕獲 subshell 的退出狀態(tài)。如果用之前 bs_exit 這種封裝,就需要在所有腳本里面都把這個函數(shù) source 進(jìn)來,成本也更高。
代碼調(diào)試
如果只想檢查腳本的語法但不執(zhí)行,可以用 sh -n 命令。如果你的腳本是一個有破壞性或者很耗時的操作,可以用這個技巧來調(diào)試語法。比如:
bash -n test.sh
此外,我們還可以增強(qiáng) set -x 指令的效果,上文說過被執(zhí)行的指令前面會有 + 的前綴,它其實是是通過一個叫做 PS4 的環(huán)境變量來控制的。我們可以修改這個變量:
export PS4='+{$LINENO:${FUNCNAME[0]}} '
這里會顯示代碼所在行數(shù)(LINENO)和當(dāng)前函數(shù)名(FUNCNAME[0]),輸出效果如下:
+{11:} trap finish EXIT
+{13:} fff
./test.sh: line 13: fff: command not found
+{13:} finish
+{2:finish} err=127
+{3:finish} [[ 127 == 1 ]]
+{6:finish} echo 127
這個 PS4 變量的修改還是很有用的,因此可以放到 .zshrc 里面去。
第 6.5 章 必會系統(tǒng)命令
grep
grep 命令很容易學(xué)習(xí),它主要有兩種使用方式,一種是單獨(dú)使用,比如搜索某個文件中的內(nèi)容:
grep 'content' file.txt
或者從標(biāo)準(zhǔn)輸入中搜索內(nèi)容:
echo 'something' | grep 'some'
要想掌握好 grep,重點(diǎn)在于了解它的各種參數(shù)。下面是一些常用的參數(shù),如果不記得,后續(xù)可以用 man grep 命令來查閱。
grep 在搜索時,默認(rèn)是大小寫敏感的,但如果要搜索 mysql,它可能寫做 mysql 也可能寫做 MySQL,這就可能存在搜索不到的問題,此時可以用 -i 參數(shù):
echo 'MySQL' | grep -i 'mysql'
如果使用 -n 參數(shù)可以打印匹配行的行號,使用 -H 參數(shù)可以打印匹配文件的文件名。
默認(rèn)情況下,如果某個二進(jìn)制文件中含有搜索的關(guān)鍵詞,會顯示 Binary file ... matches,使用 -I 選項可以忽略二進(jìn)制文件,使用 -a選項可以把二進(jìn)制文件當(dāng)做文本文件來處理,從而輸出匹配的部分。
默認(rèn)情況下 grep 會展示匹配的那一行,如果想查看上下文,可以使用 -A、-B 和 -C 這三個參數(shù):
-A 3:展示匹配行以及后面的 3 行
-B 3:展示匹配行以及前面的 3 行
-C 3:展示匹配行以及前后的 3 行,等價于 -A 3 -B 3
另外一些常用的選項包括 -v,表示只顯示那些不匹配的行,-o 表示只顯示匹配的部分,-q 表示不輸出內(nèi)容,通常與 if 連用。
xargs
在前面的章節(jié)中我們介紹過,可以通過管道將多個命令串聯(lián)起來,前提是管道后面的命令要支持從標(biāo)準(zhǔn)輸入中讀取數(shù)據(jù),比如前文的 grep 命令。
然而有些命令并不支持從標(biāo)準(zhǔn)輸入中讀取,比如這樣寫是無效的:
echo 'file_name' | rm
此時我們可以借助 xargs 命令:
echo "a" | xargs rm
這條命令的原理是,xargs 會把換行符、空格、制表符、EOF等符號做為分隔符,把輸入的內(nèi)容切分為一個數(shù)組,并把數(shù)組中每一個元素作為參數(shù),放到后面的命令中執(zhí)行,用偽代碼來寫就是:
for arg in read_input; do
rm arg
done
很常見的一個坑就是,如果文件名帶有空格,比如 hello world 就會被 xargs 截斷為兩個參數(shù),顯然不符合預(yù)期。不過一般對內(nèi)容或者文件進(jìn)行過濾時,我們都會使用 grep 或 find,這兩個命令都有辦法配合 xargs。
ls | grep 'a' | tr "\n" "\0" | xargs -0 rm
用 grep 的話會繁瑣一些,需要用 tr 命令把換行符轉(zhuǎn)換成特殊字符 \0,再利用 xargs 的 -0 參數(shù),根據(jù)文檔所述,這個參數(shù)會把分隔符指定為 -0,從而避免了文件名中含有空格的影響。
用 find 也是類似的原理:
find . -print0 | xargs -0 rm
只不過它自帶了 -print0選項,寫法更簡單。
sed
sed 誕生于 1977 年,已經(jīng) 41 歲了,這么一位叔叔級別的命令至今還活躍在各種 Shell 腳本中,由此可見它是多么重要。
Mac 自帶的時 BSD 版本的 sed,因為功能較弱,我不推薦使用,建議使用 gsed,如無特殊說明,下文的介紹都是針對 gsed的。
brew install coreutils
which gsed
# /usr/local/bin/gsed
sed 和 grep 的用法類似,都是 sed pattern file 或者 echo 'xx' | sed pattern,也就是說第二個參數(shù)可以是文件,也可以從標(biāo)準(zhǔn)輸入流中讀取。
最標(biāo)準(zhǔn)的用法是進(jìn)行文本替換(也可以用 tr 命令實現(xiàn)):
echo "a b\nc d"
# a b
# c d
echo "a b\nc d" | gsed 's/a/aa/g'
# aa b
# c d
有時候我們可能不止使用一次 sed,此時可以用 -e 參數(shù)把多個命令串聯(lián)起來:
echo "a b\nc d" | gsed -e 's/a/aa/g' -e 's/b/bb/g'
在 gsed 中,還可以使用 Shell 里定義的變量:
old=a
new=aa
echo "a b\nc d" | gsed "s/$old/$new/g"
我推薦用 gsed 是因為它有一個 -i 選項,可以對文件進(jìn)行原地修改:
gsed -i 's/a/aa/g' file
sed 最核心的部分在于這里的 s/a/aa/g,它由若干個斜杠組成(其實也不一定要用斜杠,只要保持一致就行)。這里的 s 表示替換,a 表示待匹配的內(nèi)容,支持正則,aa 表示替換后的內(nèi)容,g 表示全部替換,更多的用法有:
$0 | 當(dāng)前記錄(這個變量中存放著整個行的內(nèi)容) |
---|---|
|
當(dāng)前記錄的第n個字段,字段間由FS分隔 |
FS | 輸入字段分隔符 默認(rèn)是空格或Tab |
NF | 當(dāng)前記錄中的字段個數(shù),就是有多少列 |
NR | 已經(jīng)讀出的記錄數(shù),就是行號,從1開始,如果有多個文件話,這個值也是不斷累加中 |
FNR | 當(dāng)前記錄數(shù),與NR不同的是,這個值會是各個文件自己的行號 |
RS | 輸入的記錄分隔符, 默認(rèn)為換行符 |
這些用法雖然看起來復(fù)雜,但是和 vim 一樣,每個部分就幾種寫法,然后自行排列組合即可。
gsed 在默認(rèn)情況下,會把輸入的每一行都輸出一遍,它有一個常用的選項是 -n,表示不輸出任何一行。通常與 p 命令合用,這個命令可以打印匹配的行,類似于 grep 的效果。
awk
awk 是和 sed 同時代的命令,并稱為文本處理兩大神器。個人認(rèn)為 sed 的強(qiáng)大之處在于文本匹配后的處理,而 awk 則更適合文本的結(jié)構(gòu)化處理。
這里以獲取 ip 地址的命令來介紹下:
ifconfig | sed -n -e '/127.0.0.1/d' -e '/inet /p' | awk '{print $2}'
這里 awk 的用法其實很簡單,就是打印第二列。awk 的核心在于內(nèi)建的變量:
1~$n 當(dāng)前記錄的第n個字段,字段間由FS分隔
FS 輸入字段分隔符 默認(rèn)是空格或Tab
NF 當(dāng)前記錄中的字段個數(shù),就是有多少列
NR 已經(jīng)讀出的記錄數(shù),就是行號,從1開始,如果有多個文件話,這個值也是不斷累加中。
FNR 當(dāng)前記錄數(shù),與NR不同的是,這個值會是各個文件自己的行號
RS 輸入的記錄分隔符, 默認(rèn)為換行符
awk 一個很常見的用法是 -f 參數(shù),可以指定輸入字段的分隔符:
echo "a;b;c" | awk -F';' '{print $2}'
其實理論上來說,awk 比 sed 還要強(qiáng)大,因為它是一個圖靈完備的語言,支持 for 循環(huán)等等編程思想。建議感興趣的讀者閱讀 AWK 簡明教程 了解更多 awk 的使用技巧
第 6.6 章 高效終端使用指南
別名 alias
基本用法
對于特別長的命令,可以使用 alias 來簡化它,比如前文介紹的:
alias gg="git log --color --graph --pretty=format:'%Cred%h%Creset -%C(yellow)%d%Creset %s %Cgreen(%ci) %C(bold blue) <%an>%Creset' --abbrev-commit"
如果存在同名的別名、函數(shù)、內(nèi)置命令等,調(diào)用優(yōu)先級是:
別名 > 單數(shù) > 內(nèi)置命令 > $PATH 路徑下的可執(zhí)行文件。
一般我們只用 alias 來簡化固定的長命令,由于別名不支持參數(shù),所以復(fù)雜的處理流程建議通過定義函數(shù)來解決。
高級別名
除了普通的 alias,我們還可以創(chuàng)建 后綴 alias 和 全局 alias。創(chuàng)建后綴 alias 的寫法是:
alias -s txt='less -r'
它表示對于任意命令 xxx.txt,都會被重寫為 less -r xxx.txt,也就是原來的命令作為別名的后綴出現(xiàn)。上面這個 alias 的作用是當(dāng)我們要輸出某個 txt 的文件內(nèi)容時,只要輸入 xxx.txt 就可以了,無需更多的命令。
全局 alias 就更強(qiáng)大了,它會對整個命令進(jìn)行匹配和替換,舉個栗子:
alias -g L='| less'
以前如果想用 less 去查看一個文件需要寫成 cat xxx | less,由于有了全局別名,現(xiàn)在只要寫成 cat xxx L 即可。
如果輸入命令 alias xxx L -r 它會被替換成 alias xxx | less -r。
相信讀者已經(jīng)能理解后綴 alias 和全局 alias 的用法,但請慎用,尤其是全局 alias,類似于 C 語言的宏定義,濫用可能會帶來一些危險,建議先看看大神是怎么用的,這里面提供了很多 alias,如果不是必要,我建議盡量避免自行添加。
查看定義
如果只是想查看別名或者函數(shù)的定義,可以使用 which 命令:
但如果拿到別人的配置腳本,想自行定制。顯然只知道定義是不夠的,還得知道這個 alias 或者函數(shù)是在哪個文件里被定義的,這樣才好去修改,此時建議使用我配置的 bswhich 命令:
這是因為查找 alias 定義位置和函數(shù)定義位置的方法還不一樣,完整的寫法是:
function bswhich() {
if `type $1 | grep -q 'is a shell function'`; then
type $1
which $1
elif `type $1 | grep -q 'is an alias'`; then
PS4='+%x:%I>' zsh -i -x -c '' |& grep '>alias ' | grep "${1}="
fi
}
Autojump
如果不想每次都輸入 cd 再輸入 ls,那么 autojump 是必裝的神器:
brew install autojump
它會記住每一次 cd 的路徑,并且保存在數(shù)據(jù)中,以后我們可以直接輸入 j + 關(guān)鍵字,從而避免頻繁的 cd。
終端命令自動補(bǔ)全
輸入快捷鍵 Ctrl + E 可以根據(jù)當(dāng)前提示快速補(bǔ)全,快捷鍵 ; 可以補(bǔ)全并執(zhí)行
終端 Finder 模擬器:r
系統(tǒng)的 Finder 其實并沒那么好用,最大的問題在于沒法和 Shell 有效的交互,比如復(fù)制移動文件、在當(dāng)前文件夾位置打開終端都很不方便。
作為程序員,我推薦使用 Ranger 來瀏覽文件目錄,它是一個使用 Vim 鍵位映射的文件管理工具。
使用快捷鍵 r 來打開 ranger,它的完整定義是:alias r='source ranger',這樣做的好處在于當(dāng) Ranger 中目錄發(fā)生變化時,可以改變外部 Shell 的路徑。
在 Ranger 中,使用 j/k 來上下移動光標(biāo),h/l 來進(jìn)行目錄的前進(jìn)和后退。
常用的操作有:
zh:切換是否顯示系統(tǒng)隱藏文件,按一次打開,再按一次關(guān)閉
x:安全刪除文件(放入垃圾箱中而不是 rm)
yy:復(fù)制,dd:剪貼,pp:粘貼,空格鍵多選文件
gh:進(jìn)入用戶目錄($HOME)
yn:復(fù)制文件名,yd 復(fù)制文件夾名,yp 復(fù)制完整路徑名
:j:和 autojump 一樣,輸入要跳轉(zhuǎn)的地方
Ctrl + f:利用 fzf 搜索文件
f:當(dāng)前目錄內(nèi)過濾文件名
du:查看當(dāng)前目錄內(nèi)各文件夾大小
oo:在 Finder 中打開,op 或回車鍵:使用系統(tǒng)默認(rèn)的程序打開,oc:使用 VSCode 打開(如果已經(jīng)有 VSCode 進(jìn)程,為了加快速度,則使用已存在的)
m:添加書簽,um:選擇要刪除的書簽,```:展示書簽
fzf:模糊搜索神器
fzf 是一個模糊搜索神器,^t 是特定語義下的補(bǔ)全快捷鍵,^i 是默認(rèn)快捷鍵,很少用到:
輸入 kill 然后按下 ^t 鍵,就會打開 fzf 補(bǔ)全界面,通過輸入進(jìn)程名來獲取到 PID
類似的還有輸入 ssh、export、unset、unlias 等命令
按下 alt + c 可以列出當(dāng)前目錄下的文件夾,并快速進(jìn)入
按下 ^g,會自動補(bǔ)全 autojump 的路徑列表
按下 ^r 進(jìn)入命令歷史模式,此時也會自動打開 fzf 補(bǔ)全界面,自動補(bǔ)全命令
注意此時的補(bǔ)全并不會自動執(zhí)行,只會把命令粘貼到命令行中,如果想要按下回車后自動執(zhí)行,可以用快捷鍵 ^x^r 來觸發(fā)
fzf 甚至還支持為自定義的命令添加補(bǔ)全,具體做法可以參考:Examples (completion)
第 6.7 章 常用命令推薦
bsfn:查找文件名
如果你想查找文件夾內(nèi)的某個文件,可以使用 find 命令,但默認(rèn)的 find 命令并不支持表達(dá),所以我在 personalized.sh文件中封裝了 bsfn 函數(shù),它接受一個參數(shù),可以精確匹配,也可以寫正則表達(dá)式:
比如這里我們搜索所有以 BBA 開頭,中間字符不限,以 Plugin 結(jié)尾的文件。
bsgrep:查找內(nèi)容
簡單的裝了 grep,如果不加路徑,則表示在當(dāng)前目錄下遞歸搜索。
bsfilename: 獲取文件名
這個命令可以從完整的文件路徑中獲取不帶后綴的文件名,比如
bsfilename ~/Desktop/test.py
# 輸出結(jié)果: test
bsof: 檢查系統(tǒng)端口占用
可以通過系統(tǒng)的 lsof -i:port 來檢查哪個程序占用了 port 端口,但有時候我們不想記參數(shù),或者想查找某個程序占用了哪些端口,此時可以使用 bsof。
比如查看 redis 進(jìn)程占用了哪些端口,可以輸入 bsof redis,查看哪些進(jìn)程占用了 80 端口可以輸入 bsof :80,如下圖所示:
bszip: 壓縮文件
這個命令可以快速壓縮文件,用法 bszip path_to_file,它會讀取要壓縮的文件(夾)名,然后在當(dāng)前目錄生成同名的 zip 文件
bswhich:查看定義
如果拿到別人的配置腳本,想自行定制。顯然只知道定義是不夠的,還得知道這個 alias 或者函數(shù)是在哪個文件里被定義的,這樣才好去修改,此時建議使用我配置的 bswhich 命令:
bswhich ip
bswhich gg
bssize:查看文件和文件夾大小
bssize 后面的參數(shù)可以是文件名,表示查看這個文件的大小。也可以是文件夾名,表示查看文件夾大小和文件夾內(nèi)各子目錄的大小。
bssize . 表示查看當(dāng)前目錄大小和子目錄大小,bssize / 表示查看系統(tǒng)磁盤的使用情況。具體效果如圖所示
c:使用 VSCode 編輯
首先需要集成 VSCode 的命令行工具,步驟可以參考這個鏈接
這個命令有三種用法:
如果不加任何參數(shù),會使用 VSCode 打開當(dāng)前文件夾
如果參數(shù)所代表的文件或文件夾存在,會用 VSCode 打開指定的文件夾
如果參數(shù)代表的文件不存在,會用 autojump 打開指定路徑并使用 VSCode 編輯
ow:快速打開 xcode 工程
自動查找當(dāng)前目錄下的 xcworkspace 和 xcodeproj 文件并打開, 也可以指定路徑。
proxy:展示和切換系統(tǒng)代理
如果想使用 Charles 抓包,則輸入 p on 即可將系統(tǒng)的 HTTP 和 HTTPS 代理設(shè)置為 127.0.0.1:8888
如果想使用 Shadowsocks 科學(xué)上網(wǎng),則輸入 p g 即可將系統(tǒng)的 socks 代理設(shè)置為 localhost:14179,需要自行修改端口號
如果不想使用代理,輸入 p off 可以禁用所有代理,恢復(fù)默認(rèn)設(shè)置。
輸入 p s 可以查看當(dāng)前的系統(tǒng)代理:
ppjson:終端 json 格式化
用法:
echo '{"hello": "world"}' | ppjson
效果:
encode64 和 urltool
這幾個小命令可以快速實現(xiàn)一些編碼和解碼工作:
encode64 你好
# 5L2g5aW9
decode64 5L2g5aW9
# 你好%
urlencode https://baidu.com
# https%3A%2F%2Fbaidu.com
urldecode https%3A%2F%2Fbaidu.com
# https://baidu.com
全局別名
如果只想看某個輸出的前 3 行,可以用 cat xxx H 3,這是因為 H 被全局重命名為 | head -n
如果是看輸出的后 3 行,可以用 cat xxx T 3,其中 T 被全局重命名為 | tail -n
如果是看輸出的指定行數(shù),比如第 1、3、7 行,可以用 cat xxx R 1 3 7, 其中 R 被全局重命名為 | row
如果要看某個輸出的某幾列,比如倒數(shù)第一列,可以用 cat xxx C -1,其中 C 被全局重命名為 | column
如果要在 less 中查看某個超長的輸出,可以用 cat xxx L,其中 L 被全局重命名為 | L
如果要忽略某條命令的報錯,可以用 command NE,其中 NE 被全局重命名為 2> /dev/null
如果要某個命令完全不輸出內(nèi)容,可以用 command NUL,其中 NUL被全局重命名為 > /dev/null 2>&1
文本處理
使用 column 獲取指定的列,或使用 row 獲取指定的行:
echo "a b c" | column 1 3
# a c
echo "a\nb\nc\n" | row 1 3
# a
# c
使用 ncolumn 過濾指定的列,或使用 nrow 過濾指定的行:
echo "a b c" | ncolumn 2
# a c
echo "a\nb\nc\n" | nrow 2
# a
# c
使用 average 對列求平均,使用 add 對列求和:
echo "1\n3\n5" | average
# 5
echo "1\n3\n5" | add
# 9