Mac 高效开发指南(三)

第 5.3 章 分支与 Tag

分支

分支的本质

分支可以简单的理解为一个指针,指向某个提交。而每个提交都记录了它的父提交,从而形成了一个链表。当某个分支上不断产生提交时,分支指向的提交就会发生改变,不断向后移动,相当于这个链表在不断延长。

查看分支

输入 gb,它会展示所有本地分支,等价于命令 git branch, 输入命令 gbv 可以额外显示每个分支的最后一次提交和这个分支跟踪的远程分支,等价于命令 git branch -vv
输入 gb branch_name 表示创建一个分支,指向当前提交,gb branch_name commit 表示新建一个分支并指向某个 commit,注意这两个命令都不会切换分支。
输入 gba,查看本地和远程分支,等价于命令 git branch -a,输入命令 gbr,查看远程分支,等价于命令 git branch --remote

删除分支

输入 gbd branch_name 删除某个分支,等价于命令 git branch -d
并非所有分支都可以通过 gbd 命令删除,可以通过 git branch --merged 来查看已经合入到某个指针\(默认是 HEAD\)的分支,换句话说是可以通过这个指针回溯到的分支。这个命令也简写为 gbm
gbm 列出的分支都是可以抵达的,因此可用 gbd 删除,而 gbnm 列出的则是不可达的分支,因此不能用 gbd 删除,它是 git branch --no-merged 命令的简写
如果真的要强行删除,可以用 gbD 命令,它是 git branch -D 的简写

切换分支

输入 gco branch 可以 checkout 到某个分支上,等价于命令 git checkout,注意如果有未提交的改动,请不要切换分支。
输入 gcb new_branch 可以创建分支 new\_branch 并切换到这个分支上,它是 git checkout -b 命令的缩写,等价于 git branch new_branch 和 git checkout new_branch 这两条命令

有时候我们要从远程仓库检出一个新的分支,比如叫 feature 吧,有几种思路:

git branch -t feature origin/feature
git checkout -b feature origin/feature
git checkout --track origin/feature

第一种写法不太合适,因为它只会创建分支并且跟踪远程分支,并不会切换。我想一般创建分支的时候都是需要切换的,否则你创建它干嘛呢,可以等到要切换的时候再创建呐。

第二种写法稍微高级些,它和第一种写法一致并且可以切换分支,之前的 tips 中介绍过 gcb 后面加单个参数的含义和用法,这里第二个参数表示跟着远程分支。

第三中方法最简单,因为它参数少,而且功能和第二种写法一样,我给他起的别名叫 gct,对应 git checkout --track

如果想为当前分支设置跟踪的远程分支,输入 gtrack 即可,不需要携带参数,它会自动让当前分支跟踪远程的同名分支

Tag

标签是分支功能的子集,可以理解为不能移动的分支。前文说过,分支始终指向某个链表的开端,是可以移动的。但 Tag 始终指向某个固定的提交,不会移动。

使用 gco 同样可以切换到某个分支上。其他常用命令如下:

使用命令 gt tag_name 可以打 tag,它是 git tag 命令的缩写。
使用命令 gtd tag_name 可以删除本地 tag,它是 git tag -d 命令的缩写

第 5.4 章 代码修改

Stash

这个命令用来储藏当前未提交的改动,我配置了两个别名:

gst:用来储藏改动,是 git stash -u 的缩写,-u 参数表示未跟踪的文件\(untracked\) 也会被储藏。
gsp :用来复原储藏,是 git stash pop --index 的缩写,--index 表示会试图还原此前的索引状态。比如原来改动了两个文件 a 和 b,其中文件 a 已经被添加到暂存区(add)但 b 没有。普通的 git stash pop 会把 a 和 b 都还原为未暂存状态。添加 --index 参数则会将 a 还原为暂存状态。

Reset

很多人可能大概知道 reset 有三种模式,很多文章上来就开始介绍这三种模式的异同,在我看来这不是一种很好的教学方式。对于不是特别了解 Git 模型的读者来说,有必要介绍一些基础知识。

首先,在 Git 的思维中,它会管理三块不同的区域,工作区、暂存区和历史区。假设我们 clone
下来一个新的项目,此时三个区域内的文件内容是一模一样的。此时如果输入 git status命令会没有任何输出。注意,这个命令并不会记录改动,而是时刻比较 工作区和暂存区 以及 暂存区和历史区 之间的差异,从而得出待暂存和待提交的文件列表。

当我们开始写代码时,文件发生了变动,这其实是更改了工作区中的代码,但暂存区和历史区是一致的,因此此时 git status 会显示有一些文件需要暂存,但不会提示有文件需要提交。使用
git add 命令后,它会把改动的文件和要提交的部分拷贝到暂存区中,此时工作区和暂存区是一致的,但暂存区和历史区的文件不一样,所以不会再显示有文件需要暂存,只会提示有文件要提交。输入 git commit 以后三个区域内的内容又保持一致了,因此 git status 不会再有任何输出。

在阅读 reset 的用法前,请务必确保你真的读懂了之前的两段话,否则请读到明白为止。

首先,reset 有两种用法,它的第一个参数是提交的 SHA-1,第二个参数如果不写则是整体重置,否则只重置单个文件,我们先介绍整体重置的情况。

此时,HEAD 指针一定会移动到指定的那次提交上,也就是说历史区会与指定的那次提交保持一致。如果是用 reset --soft 参数,那么重置就到此为止了。由于只有历史区被重置了,暂存区还没有发生变化,所以这个命令的作用相当于撤销了 commit,并且把他们都放入暂存区。

接下来,reset 命令会试图把暂存区也和指定的提交保持一致。因此重置完成后,暂存区和历史区保持一致,但工作区和暂存区会出现大量的不一致,所以 git status 命令会提示我们有很多文件需要暂存(add)。如果是用 reset --mixed 参数或者不加任何参数,重置就会到此为止。可见这个命令相当于撤销了 git add 和 git commit 操作。

最后,reset 命令还有一个参数是 --hard,它会试图把工作区也和指定的提交保持一致。这个命令是不安全的,如果工作区内的文件还没有提交,它就会丢失。提交过的文件可以用 git reflog 找回。重置结束后,三个区域内的文件都和指定的提交保持一致,git status 不会有任何输出。

总结一下,git reset 在重置版本时会做三件事:

让历史区与指定的提交保持一致,如果是 --soft参数则到此为止
让暂存区和历史区与指定的提交保持一致,如果不加参数或者是 --mixed 参数则到此为止
让工作区、暂存区和历史区都与指定的提交保持一致。如果是 --hard 参数就会走到这一步

注意,我们要记的是 reset 命令的本质,而不是它的外在表现。理解了它会做什么,就很容易预测这么做的结果

至于 reset 的另一个用法:重置文件,和上述规则类似,只是它不会改变历史区,自然也就不存在 --soft 参数,其他两个参数的用法和规则是完全一致的,只不过是对文件生效。

在实际使用中,我配置了这三个命令:

grh:让工作区、暂存区和历史区都与指定的提交保持一致,可以理解为撤销所有改动,是命令 git reset --hard 的简写
grm:让暂存区和历史区与指定的提交保持一致,可以理解为撤销 git add,是命令 git reset的缩写,通常我会用 grm file_name 来撤销对某个文件的暂存
grs:让历史区与指定的提交保持一致,可以理解为撤销 git commit,是命令 git reset --soft 的缩写

Checkout

除了可以在分支和 tag 间进行切换外,如果 checkout 后面加上文件名,可以将尚未暂存的文件重置为初始状态。

因此,这个命令也可以理解为仅对工作区生效的 git reset --hard,这是一个不可挽回的操作,请谨慎执行。

第 5.5 章 代码同步

Fetch

fetch 是最简单的操作,它将远程仓库的代码、 分支和 tag 都下载到本地。有些 GUI 工具会定期自动执行 fetch。

注意,如果仅仅是 fetch 代码,并不会改变本地的代码,仅仅是预先下载了远程仓库的变动而已。直到我们进行 rebase、merge、checkout、reset 等操作时,才会改动本地代码。

Rebase or Merge

rebase 和 merge 分别是合并代码的两种操作。

rebase 是变基,也就是改变某次提交的父提交。假设当前处于分支 branch_a 并执行:

git rebase branch_b

本质上是找到 branch_a 和 branch_b的公共祖先,然后将 branch_a 到这个公共祖先中的每一次提交,依次变基到 branch_b 上。

同样是上面的情况,使用merge :

git merge branch_a

则会将 branch_a 到公共祖先之间的提交压缩成一次新的提交,附加在 branch_b 的后面。

关于选择 merge 还是 rebase,一般没有明确的要求。前者忠实的保留了每次提交的真实情况,但是多人开发时频繁 merge 容易导致时间线爆炸,影响阅读。rebase 的优点在于它创造出更加优雅的提交记录,缺点则是破坏了真实的提交记录。

但假设我们有一个主分支 dev,还有一个开发分支 feature ,两者都提交了上百次代码,现在想把开发分支合入主分支,那么大概率应该使用 merge。一方面这样的合并次数很少,不会造成时间线爆炸,反倒是真实的保留了 feature 分支的提交记录,最重要的是如此庞大的两个分支合并一定会带来大量冲突。 merge 会自动把所有提交压缩成一个,只要解决一次冲突就行,但 rebase 的次数将会达到上百次,会出现大量不必要的冲突。

举个很简单的例子,假设提交 a 和 b 是两个互逆的操作,那么在 merge 就互相抵消了,但如果使用 rebase,就需要解决两次冲突。

但如果只是想从远程仓库获取代码,并且更新本地的代码,此时就更推荐 rebase 了,我配置了快捷键 gsfrs,它的完整定义是:

alias gsfrs='git stash;git fetch;git rebase;git stash pop;'

交互式 Rebase

对于已经存在但还没有推送到远程的提交记录,我们可以使用 rebase -i 去编辑他们。假设我们想修改最近三次提交,可以输入 gri head~3,它是完整写法是:

git rebase -i head~3

这个命令会展示出最近的三次提交,最老的提交在最上面,最新的提交在最下面,这是因为 git 会按照从旧到新的顺序编辑这些提交。展示的格式如下:

pick commit_id commit_message

我们可以随意调整这三行的顺序,相当于改变提交的顺序。如果把单词 pick 改成 reword 或 r,就可以修改提交记录。

git 还支持以下关键字:

edit 或 e:编辑此次提交
drop 或 d:删除此次提交
fix 或 f:将此次提交与上次提交合并

Pull

pull 可以理解为一个语法糖,因为它等价于 fetch + merge,前文已经说过日常开发时并不推荐使用 merge,只有在偶尔分支合并时才应该使用,因此 pull 也应该慎用。

Commit

前文说过通过交互式 rebase 可以修改历史提交记录,但如果只想修改上一次提交的信息,可以使用更简单的 gca 命令,它的完整写法是:

git commit --amend

然后编辑 commit_message 并退出即可。

我们都知道提交代码前,需要先将改动的文件暂存,然后再提交。但如果我们想提交所有未暂存的文件,其实还有更快速的方法:gcam,它的完整写法是:

git commit --all -m
# 实际上等价于
git add . && git commit -m

第 5.6 章 解决冲突

构造冲突

本文主要通过一个简单的 Demo 来演示如何在 Git 中解决冲突,以及相关名词的基本概念。

首先我们编辑一个名为 begin 的文件:

This is first line

然后在分支 a 上增加两行,用加号标记出来:

 This is first line
+This is 2nd line
+This is 3rd line

再在分支 b 上增加两行,用加号标记出来:

 This is first line
+This is second line
+This is third line

当我们想把分支 a 变基(rebase)到分支 b 上时,冲突必然会出现,因为两个分支修改了相同的行,git 不知道怎么处理了。此时有如下输出:

可以看到文件被标记为了 UU。U 的意思表示 updated but unmerged,两个 U 则是说明两个分支都做了修改,但还没有合并。出现 UU 基本上都意味着发生了冲突。

放弃合并

如果对解决方式没有信心,可以暂时先放弃合并,输入 git rebase --abort 即可。如果当初选择的是 merge 两个分支,那么将是 git merge --abort。

这个命令能回退到合并分支前,但它无法在正确处理工作目录中的变动。也就是说,在开始合并之前,务必确保自己的工作目录是干净的。

不过在相当多的场合,Git 会自动做出提醒。比如当你的工作目录有改动时,直接就无法 rebase:

如果 merge 会影响到工作目录的改动,Git 也会禁止你 merge,比如我随便修改 begin 的最后一行,再执行 merge 操作会得到如下错误:

所以请牢记第一点: 在 rebase 或者 merge 之前,务必确保你的工作目录是干净的

冲突描述

我们来看一下冲突的文件长什么样,注意我们是在分支 a 上 rebase 到分支 b:

可以看到两个提交之间用 ===== 来分割,上面的部分有 <<<< 这个标记,后面跟着一串 SHA1 值,它其实就是分支 b 指向的那次提交。

下面的部分写得很明确,是分支 a 指向的那次提交内容。

如果我们在分支 b 上使用 git merga branch_a,得到的效果将会是:

可见,除了对分支 b 的描述不太一样以外,冲突的内容是一样的。都是上面是 b 分支的改动,下面是 a 分支的改动。

于是得出第二个结论:冲突被多个等号分割为两部分,上面是当前的改动,而下面是将要合入的改动

冲突文件的原理

运行命令 git ls-files -u,其中 -u 参数用来展示还没有合并的改动:

可见当前存在 3 份 begin 文件,我们可以这样查看第一个文件:

git show :1:begin

第二个文件:

git show :2:begin

这个文件又被称为 ours,如果以这个文件的改动为准,将会得到等号上面的那部分结果。可以用 git diff --ours 来验证。下图表示当前冲突相对于 ours 的变化(多了下面的那部分)。

第三个文件:

git show :3:begin

这个文件又被称为 theirs,如果以这个文件的改动为准,将会得到等号下面的那部分结果,可以用 git diff --theirs 来验证。下图表示当前冲突相对于 theirs 的变化(多了上面的那部分)。

于是得出第三个结论:“我们的”指的是已有的改动,也就是等号上面部分的改动,“他们的”则是将要合入的改动,也就是等号下面部分的改动。

比如将在分支 b 上执行 merge a 或者在分支 a 上执行 rebase b,此时分支 a 的改动被叫做 ours,而分支 b 的改动则被称为 theirs。

我个人建议这样记忆:已有的分支是原住民,也就是“我们的”,将要合入的代码是入侵者,也就是 “他们的”。

解决冲突

最简单的方式就是只是用某一方的改动来解决冲突,比如我认为分支 a 的改动是无效的,分支 b 的改动才是合理的,也就以 “我们的(ours)” 改动为准,忽略将要合入的代码,可以执行命令:

git checkout --ours begin
git add begin
git commit
# 注意不用加 -m 选项,git 会默认生成一个 merge 的 message

对应到之前的 diff 输出中来,如果你只想保留等号上面的部分,可以用 --ours 参数,否则使用 --theirs 参数。

撤销合并

先讨论 merge 的情况,此时分之关系如图所示:

如果代码还没有推送到远程仓库,只要 reset --hard 到上一次提交即可:

git reset --hard HEAD~

此时效果如图所示:

如果已经推送到远程仓库,这样 reset 就不行了,此时可以用 revert 命令。我将在后续的文章中做详细的介绍。

第 5.7 章 Git 核心原理

假设我们有两个分支,a 和 b,它们的提交都有一个相同的父提交(master 指向的那次提交)。如图所示:

现在我们在分支 b 上,然后 rabase 到分支 a 上。如图所示:

平时开发中经常遇到这种情况,假设分支 a 和 b 是两个独立的 feature 分支,但是不小心被我们错误的 rebase 了。现在相当于两个 feature 分支中原本独立的业务被揉起来了,当然是我们不想看到的结果,那么如何撤销呢?

一种方案是利用 reflog 命令。

利用 reflog 撤销变基

我们先不考虑原理,直接上解决方案,首先输入 git reflog,你会看到如下图所示的日志:

最后的输出其实是最早的操作,我们逐条分析下:

HEAD@{8}: 这里我们创建了初始的提交
HEAD@{7}:检出了分支 a
HEAD@{6}:在分支 a 上做了一次提交,注意 master 分支没有变动
HEAD@{5}:从分支 a 回到分支 master,相当于向后退了一次
HEAD@{4}:检出了分支 b
HEAD@{3}:在分支 b 上做了一次提交,注意 master 分支没有变动
HEAD@{2}:这一步开始变基到分支 a,首先切换到分支 a 上
HEAD@{1}:把分支 b 对应的那次提交变基到分支 a 上
HEAD@{0}:变基结束,因为是在 b 上发起的变基,所以最后还切回分支 b

如果我们想撤销此次 rebase,只要输入以下命令就可以了:

git reset --hard HEAD@{3}

此时再看,已经“恢复”到 rebase 前的状态了。的是不是感觉很神奇呢,先别着急,后面会介绍这么做的原理。

git 工作原理简介 {#sectiongit}

为了搞懂 git 是如何工作的,以及这些命令背后的原理,我想有必要对 git 的模型有基础的了解。

首先,每一个 git 目录都有一个名为 .git 的隐藏目录,关于 git 的一切都存储于这个目录里面(全局配置除外)。这个目录里面有一些子目录和文件,文件其实不重要,都是一些配置信息,后面会介绍其中的 HEAD 文件。子目录有以下几个:

info:这个目录不重要,里面有一个 exclude 文件和 .gitignore 文件的作用相似,区别是这个文件不会被纳入版本控制,所以可以做一些个人配置。
hooks:这个目录很容易理解, 主要用来放一些 git 钩子,在指定任务触发前后做一些自定义的配置,这是另外一个单独的话题,本文不会具体介绍。
objects:用于存放所有 git 中的对象,下面单独介绍。
logs:用于记录各个分支的移动情况,下面单独介绍。
refs:用于记录所有的引用,下面单独介绍。

本文主要会介绍后面三个文件夹的作用。

git 对象

git 是面向对象的!
git 是面向对象的!
git 是面向对象的!

没错,git 是面向对象的,而且很多东西都是对象。我举个简单的例子,来帮助大家理解这个概念。假设我们在一个空仓库里,编辑了 2 个文件,然后提交。此时都会有那些对象呢?

首先会有两个数据对象,每个文件都对应一个数据对象。当文件被修改时,即使是新增了一个字母,也会生成一个新的数据对象。

其次,会有一个树对象用来维护一系列的数据对象,叫树对象的原因是它持有的不仅可以是数据对象,还可以是另一个树对象。比如某次提交了两个文件和一个文件夹,那么树对象里面就有三个对象,两个是数据对象,文件夹则用另一个树对象表示。这样递归下去就可以表示任意层次的文件了。

最后则是提交对象,每个提交对象都有一个树对象,用来表示某一次提交所涉及的文件。除此以外,每一个提交还有自己的父提交,指向上一次提交的对象。当然,提交对象还会包含提交时间、提交者姓名、邮箱等辅助信息,就不多说了。

假设我们只有一个分支,以上知识点就足够解释 git 的提交历史是如何计算的了。它并不存储完整的提交历史,而是通过父提交的对象不断向前查找,得出完整的历史。

注意开头那张图片,分支 b 指向的提交是 9cbb015,不妨来看下它是何方神圣:

git cat-file -t 9cbb015
git cat-file -p 9cbb015

这里我们使用 cat-file 命令,其中 -t 参数打印对象的类型,-p 参数会智能识别类型,并打印其中的内容。输出结果如图所示:

可见 9cbb015 是一个提交对象,里面包含了树对象、父提交对象和各种配置信息。我们可以再打印树对象看看:

这表示本次提交只修改了 begin 这个文件,并且输出了 begin 这个文件对于的数据对象。

git 引用

既然 git 是面向对象的,那么有没有指针呢?还真是有的,分支和标签都是指向提交对象的指针。这一点可以验证:

cat .git/refs/heads/a

所有的本地分支都存储在 git/refs/heads 目录下,每一个分支对应一个文件,文件的内容如图所示:

可见,4a3a88d 刚好是本文第一张图中分支 a 所指向的提交。

我们已经搞明白了 git 分支的秘密,现在有了所有分支的记录,又有了每次提交的父提交对象,就能够得出像 SourceTree 或者文章开头第一张图那样的提交状态了。

至于标签,它其实也是一种引用,可以理解为不能移动的分支。只能永远指向某个固定的提交。

最后一个比较特殊的引用是 HEAD,它可以理解为指针的指针,为了证明这一点,我们看看 .git/HEAD 文件:

它的内容记录了当前指向哪个分支,refs/heads/b 其实是一个文件,这个文件的内容是分支 b 指向的那个提交对象。理解这一点非常重要,否则你会无法理解 checkout 和 reset的区别。

这两个命令都会改变 HEAD 的指向,区别是 checkout 不改变 HEAD 指向的分支的指向,而 reset 会。举个例子, 在分支 b 上执行以下两个命令都会让 HEAD 指向 4a3a88d 这次提交(分支 a 指向的提交):

git checkout a
git reset --hard a

但 checkout 仅改变 HEAD 的指向,不会改变分支 b 的指向。而 reset 不仅会改变 HEAD 的指向,还因为 HEAD 指向分支 b,就把 b 也指向 4a3a88d 这次提交。

git 日志

在 .git/logs 目录中,有一个文件夹和一个 HEAD 文件,每当 HEAD 引用改变了指向的位置,就会在 .git/logs/HEAD 中添加了一个记录。而 .git/logs/refs/heads 这个目录中则有多个文件,每个文件对应一个分支,记录了这个分支 的指向位置发生改变的情况。

当我们执行 git reflog 的时候,其实就是读取了 .git/logs/HEAD 这个文件。

撤销 rebase 的原理

首先我们要排除一个误区,那就是 git 会维护每次提交的提交对象、树对象和数据对象,但并不会维护每次提交时,各个分支的指向。在介绍分支的那一节中我们已经看到,分支仅仅是一个保留了提交对象的文件而已,并不记录历史信息。即使在上一节中,我们知道分支的变化信息会被记录下来,但也不会和某个提交对象绑定。

也就是说,git 中并不存在某次提交时的分支快照

那么我们是如何通过 reset 来撤销 rebase 的呢,这里还要澄清另一个事实。前文曾经说过,某个时刻下你通过 SourceTree 或者 git log 看到的分支状态,其实是由所有分支的列表、每个分支所指向的提交,和每个提交的父提交共同绘制出来的。

首先 git/refs/heads 下的文件告诉我们有多少分支,每个文件的内容告诉我们这个分支指向那个提交,有了这个提交不断向前追溯就绘制出了这个分支的提交历史。所有分子的提交历史也就组成了我们看到的状态。

但我们要明确:不是所有提交对象都能看到的,举个例子如果我们把某个分支向前移一次提交,那个分支的提交线就会少一个节点,如果没有别的提交线包含这个节点,这个节点就看不到了。

所以在 rebase 完成后,我们以为看到了下面这样的提交线:

df0f2c5(master) --- 4a3a88d(a) --- 9cbb015(b)

实际上是这样的:

df0f2c5(master) --- 4a3a88d(a) --- 9d0618e(b)
   |
9cbb015

master 分支上依然有分叉,原来 9cbb015 这次提交依然存在,只不过没有分支的提交线包含它,所以无法看到而已。但是通过 reflog,我们可以找回 HEAD 头的每一次移动,所以能看到这次提交。

当我们执行这个命令时:

git reset --hard HEAD@{3}

再看一次 reflog 的输出:

HEAD@{3} 其实是它左侧 9cbb015 这次提交的缩写,所以上述命令等价于:

git reset --hard 9cbb015

前文说过,reset 不仅会移动 HEAD,还会移动 HEAD 所指向的分支,所以这个命令的执行结果就是让 HEAD 和分支 b 同时指向 9cbb015 这个提交,看起来像是撤销了 rebase。

但别忘了,分支 a 的上面还是有一次提交的,9d0618e 这次提交仅仅是没有分支指向它,所以不显示而已。但它真实的存在着,严格意义上来说,我们并没有真正的撤销此次 rebase

第 6 章 终极武器 Zsh

Shell 是一个非常庞大的话题,它的学习路线和普通的编程语言不一致,使用场景在很多人看了也不多。但其实 Shell 是非常强大的胶水语言,能把其它各个模块和系统很好的串联起来,同时由于 shell 非常底层,更加接近操作系统,所以非常用来和系统的软硬件生态打交道。

与单纯的研究 shell 语法,和系统、运维开发不同的是,我更多的是希望降低 shell 的学习门槛,坚持实用主义,把那些能够提高日常开发效率的知识介绍给读者。在掌握这些基本原理后,读者就可以根据自己的实际情况进行定制了。本章主要分为三个阶段:

首先会介绍最简单,但是最容易被忽视的,shell 脚本的基本概念和模型。如果不了解 shell 脚本是怎么被执行的,虽然不影响使用,但在理解更深一层的概念时就会遇到困难。

接下来是 shell 的基本语法,因为 shell 的写法比较多,同一个功能有多种方式完成,对新人不太友好。所以这里会把所有基本的语法都整理出来,然后就只剩下拼接组合的工作了。

最后则是最精彩的部分,利用这些 shell 知识和脚本,让自己的电脑更加强大,简单,易用。

shell 的学习是持之以恒的过程,希望读完本章后,读者能掌握基本的概念,在后续的使用过程中不断发现痛点,解决痛点,提高自己的能力。

第 6.1 章 Shell 基本模型与运行原理

除了在命令行中直接输入命令,我们还可以把多个命令汇总在一起,放在脚本中,便于后期一起执行。本节主要介绍如何执行 Shell 脚本。这个问题看起来很简单, 但如果不把其中原理想清楚,会导致后期理解上存在偏差,容易踩坑。

假设当前目录下有一个 test.sh文件,内容如下:

cd ~/Downloads

一共有三类方式执行它:

调用解释器执行

顾名思义,就是把脚本的路径传入解释器中去执行:

sh test.sh
# 或者
bash test.sh

这两者略有区别,我没有整理过完整的差异,但至少对于 echo 命令来说,以下命令在两种解释器下得到的结果是不一样的:

echo -e "hello\nworld"

bash 会正确的将 \n 解释为换行符,sh 则不能。个人建议统一使用 bash 即可

利用解释器执行 Shell 脚本,实际上是在当前的 Shell 环境中启动了一个子进程去执行。

直接输入文件名运行

下面这行命令同样可以用来执行脚本:

./test.sh

此时要求脚本文件必须是可执行的,否则将会报错:

zsh: permission denied: ./test.sh

解决方法是变更文件的权限:chmod +x test.sh 然后再执行就可以了。这种运行方式也是新建一个子 Shell 去执行脚本。

有的读者可能会问,系统怎么知道这是 Shell 脚本而不是其他语言呢?实际上并不是通过文件名后缀来区分的,可以举个例子:

#! /usr/bin/python

print "11"

这里用 Python 语法写了一个脚本,但是后缀名保存为 sh,如果我们直输入名称去执行,一样可以得到正确的结果:

./py.sh
# 输出 11

这里的 #! 被称为 shebang,用来指定使用什么解释器去执行脚本。因此,规则可以简单概括如下:

如果直接写明了解释器,比如 sh xxx.sh,会以显式指定的解释器为准,shebang 不生效。
如果直接写可执行文件的名字,则以 shebang 指定的解释器为准。
如果没有指定 shebang,默认是 bash,不会参考文件名的后缀。

在当前 Shell 运行

与前两种方式不同的是,我们还可以在当前的 Shell 中执行脚本:

. ./test.sh
# 或者
source ./test.sh

这种做法的好处在于,由于不涉及到 Shell 进程的切换,所有变量和函数的定义都是相通的。比如我在 util.sh 里面定义了函数:

function sayHello {
    echo "hello, world"
}

使用方可以通过 source 命令来获取调用函数的能力:

source util.sh
sayHello

通过 . 和 srouce 来调用脚本基本上是一致的,区别在于 source 的兼容性更好,因此更加推荐。

第 6.2 章 Shell 变量与基础语法

Shell 变量

定义变量

定义变量时,千万不要在等号的两边加空格,否则会报错。正确的做法是:

a=1
echo $a

引用变量

正如上面代码所示,用 $ 加上变量名就可以引用变量,有时候我们还会看到另一种写法:

a=1
echo $a
# 下面这种写法也是一样的,而且更推荐
echo ${a}

一般来说,用大括号把变量名括起来是多此一举,两者作用相同。但如果我们要做字符串拼接,可以这样写:

a=hello
echo ${a}wrold  # 输出 helloworld
echo $awrold    # 变量 awrold 并不存在

在 bash/zsh 的语法中,不需要使用专门的字符串拼接函数,只要把两个变量写在一起即可。

引号

bash/zsh 中有单引号和双引号之分,区别在于单引号中的内容完全是字面量,甚至单引号中都无法使用转义字符再打印出单引号。

双引号中,如果遇到变量,将会自动转换为变量的值。

echo "\"\""    # 输出 ""
echo '\'\''    # 没有输出,因为单引号内部都是字面量

a=1
echo "$a"      # 输出 1
echo '$a'      # 输出 $a,因为单引号不支持变量的展开

注意!!!在 bash 中,如果变量是一个字符串,而且字符串中含有空格,用双引号括起来的字符串将会被解析为一个独立的单词,直接用 $ 则会被 bash 解析为多个参数。

假设当前目录下有个名叫 a b 的文件夹:

p="a b"
cd $p    # bash: cd: a: No such file or directory
# 这是因为上述命令等价于 cd a b
# 字符串 "a b" 的值被拆开传到 cd 命令中,但是 cd 只接收一个参数,导致路径错误
# 正确的做法如下
cd "$p"
# 等价于 cd "a b",路径正确

再次强调,这个问题只在 bash 中存在,如果我们平时在终端中使用 zsh,但是脚本用 bash 执行,就会遇到这个问题。

*nix 系统下任何文件夹、文件名严禁留空格,严禁带中文,利人利己!

变量作用域

默认情况下,变量的作用域是当前的 Shell,即使变量定义在函数中也是如此:

function t() {
    temp=111
}

echo $temp   # 没有输出,变量 temp 未定义
t            # 调用函数,函数内部会定义变量
echo $temp   # 输出 111

因此函数内部的变量要加上 local 关键字才不会污染全局作用域:

function t() {
    local temp=111
}

t            # 调用函数
echo $temp   # 没有输出

环境变量

bash/zsh 中的变量分为普通变量和环境变量两种,区别就在于,当我们从当前的 shell 中打开一个新的 subshell 时,环境变量会被 subshell 继承,普通变量则不会。

a=1        # 定义一个普通变量 a
bash       # 打开一个新的 
echo $a    # 没有输出,因为 subshell 只会继承父 shell 的环境变量

我们可以验证下变量 a 确实是普通变量,而不是环境变量:

# 单独运行 set 命令可以打印出所有普通变量
set | grep 'a=' # 能看到变量 a 的定义

# 单独运行 env 命令可以打印出所有的环境变量
env | grep 'a=' # 没有输出,说明变量 a 不是环境变量

因此,如果想让某个变量的作用域延伸到 subshell 中,就需要把它定义为环境变量。有两种写法都可以做到:

 export a=1
 declare -x a=1

除了在定义时导出为环境变量,也可以把已经定义过的普通变量导出为环境变量:

export a
declare -x a

默认全局变量

PWD

记录当前所在的目录,通过 echo $PWD 查看

OLDPWD

表示上一次所在的目录,输入减号 - 可以快速跳转到上一次所在的目录

特殊变量

bash/zsh 中还有一些约定好的只读变量,它们的值在脚本的运行过程中动态确定,常见的有:

$0:表示脚本名字,可能是相对路径,当我们执行 bash a/b/c/d.sh 是,$0 的值是 a/b/c/d.sh
$1、$2、……、$10:用来表示参数,$1 表示第一个参数,以此类推
$#:表示参数个数
$?:表示上一个命令的执行结果,0 表示正常结束,非 0 表示出现错误

第 2、3 条规则在函数内部同样适用。

基础语法

条件判断

shell 的判断有两种写法,分别是 [ 和 [[,举个例子,下面两种写法都是正确的:

abc="1"
if [[ $abc = "1" ]]; then
    echo "equal"
fi

# 或者
if [ $abc = "1" ]; then
    echo "equal"
fi

虽然两者看起来很类似,但 [ 很早就有了,它的本质是调用内置的 test 指令,而 [[ 的诞生则相对晚的多,它是 bash/zsh 的语法。

在实际开发中,两者的细节差异较大,对初学者非常不友好,我的建议是统一使用 [[。

shell 中的判断可以分为数字比较、字符串比较和文件判断等几大类。

数字判断

判断两个数字相等有三种写法:单等号、双等号或 -eq 关键字:

[[ $abc = 1 ]] && echo "yes" || echo "not"
[[ $abc == 1 ]] && echo "yes" || echo "not"
# 或者 
[[ $abc -eq 1 ]] && echo "yes" || echo "not"
# 输出结果都是 yes

不等号可以用 != 或 -ne 表示,大于号可以用 > 或者 -gt 来表示,小于号用 < 或者 -lt 表示。这几个英文单词不必记忆。

但 shell 不支持 “大于等于”、“小于等于”这些判断,前者用 -ge 表示,后者用 -le 表示。

字符串判断

字符串的判等和数字一致,不同的是可以判断字符串是否为空:

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 ]]判断文件夹是否存在,必须是文件夹,不能是文件

逻辑运算符

其它语言的几种逻辑运算符可以正常使用:

[[ ! $str == h*lo || 1 = 1 ]] && echo "yes" || echo "not"
# 第一个判断取反,结果为 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

循环

for 循环的语法和 if 比较类似:

for f in `ls`; do
    echo $f
done

第 6.3 章 命令串联

管道

管道是 shell 中最常用的概念之一,它允许不同脚本、命令之间互相传递数据,举一个最常见的例子:

ls | grep 'a'

默认情况下,命令 ls 会把当前目录下的文件输出到屏幕上,但如果通过管道符号 |,它就会把输出结果传递给下一个命令。

命令 grep 恰好支持从管道中读取数据,因此上面这行脚本的含义实际上是在当前目录内寻找名称含有字母 a 的文件

我们可以自己模拟一下:

function before {
    echo 'output'
}

function after {
    read in
    echo "Read from pipiline: "${in}
}

before | after
# 输出结果为:
# Read from pipiline: output

重定向

说到管道,就不得提提它的孪生兄弟:重定向,最简单的使用场景就是把原本输出到屏幕的内容,重定向到文件中。

当然,这只是重定向最简单的用途,如果不了解背后的运行原理,就会影响到后续的使用。

首先,*nix 系统中有三种特殊的文件描述符,其中 0 表示标准输入,它一般指的是我们的键盘,1 表示标准输出,2 表示错误输出,它们一般都表示屏幕。所以 Shell 可以理解为一个盒子,它从 0(标准输入,也就是键盘)读取命令,没有错误的话就输出到 1(标准输出),命令执行错误的话输出到 2(错误输出),最终都会在屏幕上显示出来。

举一个例子,请看下面这行代码:

ls exist.sh not_exist.sh 1>success 2>fail

这行代码的意思首先是要展示两个文件,假设一个文件存在,另一个文件不存在(从名字就能看出来了),这样会产生一行标准输出和一行错误输出。1>success 的意思是把标准输出重定向到 success 这个文件,类似的,2 > fail 表示把错误信息输出到 fail 这个文件。

类似的语法还可以写成:

ls exist.sh not_exist.sh >success 2>&1

这是因为如果 > 前面不加数字,默认是标准输出。而 2>&1 则表示让错误输出使用和标准输出相同的重定向方式。因此这个命令等价于 ls exist.sh not_exist.sh 1>success 2>success。

从严格意义上讲,使用 2>&1 的效率更高一些,因为它会复用标准输出的管道。

过滤输出

有了上述背景的积累,我们来看一个实际的问题。有时候在 Shell 脚本中我们只希望用到一个命令的功能, 但不希望它产生任何输出,此时可以使用如下命令:

command > /dev/null 2>&1

这行命令表示把标准输出和错误输出都重定向到 /dev/null 文件,只是一个特定的文件,可以理解为\黑洞\。因为任何内容都可以写入这个文件,但对这个文件的读取永远会返回 EOF,也就是输入的任何内容都会被抛弃掉。

上述命令还可以简写为 command &>/dev/null,没有什么理由和解释,只不过是 > /dev/null 2>&1 缩略写法。

除了使用 > /dev/null 这种写法,还可以使用 >&-,它不表示重定向,而是表示直接关闭某种输出。自然屏幕上也就没有任何内容了。

更多类似的技巧请参考这篇文章:Difference between 2>&-, 2>/dev/null, |&, &>/dev/null and >/dev/null 2>&1
输入重定向

如果要想拷贝某个文件中的内容到剪贴板,笨的人打开文件按下 Command + A 和 Command + C,聪明一些的人会输入下面这个命令:

cat file | pbcopy

这种写法其实还可以再提高一下效率,因为它会读取文件,然后把原本输出到标准输出(屏幕)的内容通过管道转到 pbcopy 这个命令上。

更高效、更直接的写法如下:

pbcopy < file

这样可以减少一次 IO 操作

函数返回值

在函数的结尾可以使用 return 关键字,然而需要注意的是,调用函数后的返回结果,并不是 return 的内容,而是 echo 的内容。至于 return 的内容,则可以通过 $? 这个特殊变量来读取。

function foo {
    echo 'output'
    return 1
}

a=`foo`
echo $?        # 输出 1
echo $a        # 输出 output

在 if 语句中,除了可以进行普通的判断外,还可以直接根据命令的执行结果进行判断。此时读取的依然是 return 的结果。

前文中提过,正常执行的命令返回值是 0,对应到 if 语句中则是 true 分支:

function foo {
    return 1
}

if test ; then
    echo "1"
else
    echo "0"
fi

# 因为函数返回 1,表示执行失败,所以最终输出 0

前面曾经介绍过如何判断当前目录下是否存在某个文件,放到 if 中就可以写为:

if `ls | grep -q 'a'` ; then
    echo "yes"
else
    echo "no"
fi

这里虽然介绍的是函数返回值,但对整个脚本同样适用

第 6.4 章 Shell 错误处理

本文前面部分内容摘录自阮一峰老师的:Bash 脚本 set 命令教程,主要是文章写得太好了。

开启错误处理

使用 shell 中的错误处理有助于我们发现错误,更好的调试代码。

检测未定义变量

首先,set -u 可以在遇到未定义变量时抛出错误,而不是忽略它。比如:

echo $bar

这里的变量 bar 没有定义,shell 的默认方案是忽略掉它。这就可能带来隐藏的问题,所以通过 set -u 选项来强制报错:

set -u
echo $bar

此时会得到报错 ./test.sh: line 2: bar: unbound variable

报错时退出

如果某个命令执行错了,可能会导致后续一系列命令执行出错。既不利于调试,也会导致很多意想不到的结果,所以可以用 set -e 选项来强制报错时退出执行脚本。

set -e
bbbb
ssss

如果不加上 set -e 会得到两行报错,因为 bbbb 和 ssss 都是不存在的指令。而加上以后,这里只会有一个报错就立刻 exit 了。

需要注意的是,如果我们用管道的写法,得到的返回值是最后一个命令的返回值,如果中间的命令出错,是不能被 set -e 捕获的,比如:

set -e

bs | ls
echo 'reach here'

得到的输出结果将是:

aaa.sh: line 3: bs: command not found
test.sh
reach here

可见 bs 这个指令虽然不存在,但程序还是没有退出,而是执行到了结尾。因此 set -e 通常需要配合 set -o pipfail 来使用,这样管道中的任何一个指令出错,都会导致程序退出。

调试执行

如果想知道每一行都执行了什么代码,可以用 set -x 选项,通常我们在 Jenkins 等工具里可以这么用,方便追查问题。比如:

set -x
ls

我们会得到:

+ ls
test.sh

以加号开头的行就是文件的原始内容了。

exit 钩子

总结一下第一段的内容,我们在任何 shell 脚本的开头都应该加上这行标记:

set -euo pipefail

表示遇到错误指令或未定义的变量时立刻退出。当然,如果需要调试,可以改成 set -euxo pipefail。

我们知道退出是靠 exit 命令来实现的,也就是说上述错误最终都会调用到 exit 命令,有没有办法捕获这个退出呢?

最简单做法当然是封装 exit,比如:

function bs_exit() {
    echo "exit" && exit $1
}

但如果项目中已有大量的 exit,就需要我们手动替换。虽然成本能接受,但如果可以用 AOP 的方式来 hook exit 命令,肯定是最理想的。

这也是本文的重点,经过查阅资料,我们可以这样写:

function finish {
  err=$?
  if [[ $err == 1 ]]; then
    echo '1'
  fi
}

trap finish EXIT

这里的 trap 是一个内置命令,用来捕捉发送给程序的信号。它接受两个参数,第一个是处理信号的方式,第二个则是信号名。

比如当我们使用 exit 命令来退出脚本时,实际上是发送了 EXIT 信号,于是会被捕获,并调用 finish 函数。函数内部可以拿到 exit 后面的状态,因此可以区分用户是通过 exit 1 还是 exit 2 来退出的,方便执行对应的操作。

这种写法的另一个好处在于它是全局的,比如当我的 shell 脚本存在嵌套调用关系时,只要在入口处定义一次就好,它可以自动捕获 subshell 的退出状态。如果用之前 bs_exit 这种封装,就需要在所有脚本里面都把这个函数 source 进来,成本也更高。

代码调试

如果只想检查脚本的语法但不执行,可以用 sh -n 命令。如果你的脚本是一个有破坏性或者很耗时的操作,可以用这个技巧来调试语法。比如:

bash -n test.sh

此外,我们还可以增强 set -x 指令的效果,上文说过被执行的指令前面会有 + 的前缀,它其实是是通过一个叫做 PS4 的环境变量来控制的。我们可以修改这个变量:

export PS4='+{$LINENO:${FUNCNAME[0]}} '

这里会显示代码所在行数(LINENO)和当前函数名(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 章 必会系统命令

grep

grep 命令很容易学习,它主要有两种使用方式,一种是单独使用,比如搜索某个文件中的内容:

grep 'content' file.txt

或者从标准输入中搜索内容:

echo 'something' | grep 'some'

要想掌握好 grep,重点在于了解它的各种参数。下面是一些常用的参数,如果不记得,后续可以用 man grep 命令来查阅。

grep 在搜索时,默认是大小写敏感的,但如果要搜索 mysql,它可能写做 mysql 也可能写做 MySQL,这就可能存在搜索不到的问题,此时可以用 -i 参数:

echo 'MySQL' | grep -i 'mysql'

如果使用 -n 参数可以打印匹配行的行号,使用 -H 参数可以打印匹配文件的文件名。

默认情况下,如果某个二进制文件中含有搜索的关键词,会显示 Binary file ... matches,使用 -I 选项可以忽略二进制文件,使用 -a选项可以把二进制文件当做文本文件来处理,从而输出匹配的部分。

默认情况下 grep 会展示匹配的那一行,如果想查看上下文,可以使用 -A、-B 和 -C 这三个参数:

-A 3:展示匹配行以及后面的 3 行
-B 3:展示匹配行以及前面的 3 行
-C 3:展示匹配行以及前后的 3 行,等价于 -A 3 -B 3

另外一些常用的选项包括 -v,表示只显示那些不匹配的行,-o 表示只显示匹配的部分,-q 表示不输出内容,通常与 if 连用。

xargs

在前面的章节中我们介绍过,可以通过管道将多个命令串联起来,前提是管道后面的命令要支持从标准输入中读取数据,比如前文的 grep 命令。

然而有些命令并不支持从标准输入中读取,比如这样写是无效的:

echo 'file_name' | rm

此时我们可以借助 xargs 命令:

echo "a" | xargs rm

这条命令的原理是,xargs 会把换行符、空格、制表符、EOF等符号做为分隔符,把输入的内容切分为一个数组,并把数组中每一个元素作为参数,放到后面的命令中执行,用伪代码来写就是:

for arg in read_input; do
    rm arg
done

很常见的一个坑就是,如果文件名带有空格,比如 hello world 就会被 xargs 截断为两个参数,显然不符合预期。不过一般对内容或者文件进行过滤时,我们都会使用 grep 或 find,这两个命令都有办法配合 xargs。

ls | grep 'a' | tr "\n" "\0" | xargs -0 rm

用 grep 的话会繁琐一些,需要用 tr 命令把换行符转换成特殊字符 \0,再利用 xargs 的 -0 参数,根据文档所述,这个参数会把分隔符指定为 -0,从而避免了文件名中含有空格的影响。

用 find 也是类似的原理:

find . -print0 | xargs -0 rm

只不过它自带了 -print0选项,写法更简单。

sed

sed 诞生于 1977 年,已经 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,也就是说第二个参数可以是文件,也可以从标准输入流中读取。

最标准的用法是进行文本替换(也可以用 tr 命令实现):

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 参数把多个命令串联起来:

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 选项,可以对文件进行原地修改:

gsed -i 's/a/aa/g' file

sed 最核心的部分在于这里的 s/a/aa/g,它由若干个斜杠组成(其实也不一定要用斜杠,只要保持一致就行)。这里的 s 表示替换,a 表示待匹配的内容,支持正则,aa 表示替换后的内容,g 表示全部替换,更多的用法有:

$0 当前记录(这个变量中存放着整个行的内容)
1~n 当前记录的第n个字段,字段间由FS分隔
FS 输入字段分隔符 默认是空格或Tab
NF 当前记录中的字段个数,就是有多少列
NR 已经读出的记录数,就是行号,从1开始,如果有多个文件话,这个值也是不断累加中
FNR 当前记录数,与NR不同的是,这个值会是各个文件自己的行号
RS 输入的记录分隔符, 默认为换行符

这些用法虽然看起来复杂,但是和 vim 一样,每个部分就几种写法,然后自行排列组合即可。

gsed 在默认情况下,会把输入的每一行都输出一遍,它有一个常用的选项是 -n,表示不输出任何一行。通常与 p 命令合用,这个命令可以打印匹配的行,类似于 grep 的效果。

awk

awk 是和 sed 同时代的命令,并称为文本处理两大神器。个人认为 sed 的强大之处在于文本匹配后的处理,而 awk 则更适合文本的结构化处理。

这里以获取 ip 地址的命令来介绍下:

ifconfig | sed -n -e '/127.0.0.1/d' -e '/inet /p' | awk '{print $2}'

这里 awk 的用法其实很简单,就是打印第二列。awk 的核心在于内建的变量:
0 当前记录(这个变量中存放着整个行的内容)1~$n 当前记录的第n个字段,字段间由FS分隔
FS 输入字段分隔符 默认是空格或Tab
NF 当前记录中的字段个数,就是有多少列
NR 已经读出的记录数,就是行号,从1开始,如果有多个文件话,这个值也是不断累加中。
FNR 当前记录数,与NR不同的是,这个值会是各个文件自己的行号
RS 输入的记录分隔符, 默认为换行符

awk 一个很常见的用法是 -f 参数,可以指定输入字段的分隔符:

echo "a;b;c" | awk -F';' '{print $2}'

其实理论上来说,awk 比 sed 还要强大,因为它是一个图灵完备的语言,支持 for 循环等等编程思想。建议感兴趣的读者阅读 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"

如果存在同名的别名、函数、内置命令等,调用优先级是:

别名 > 单数 > 内置命令 > $PATH 路径下的可执行文件。

一般我们只用 alias 来简化固定的长命令,由于别名不支持参数,所以复杂的处理流程建议通过定义函数来解决。

高级别名

除了普通的 alias,我们还可以创建 后缀 alias 和 全局 alias。创建后缀 alias 的写法是:

alias -s txt='less -r'

它表示对于任意命令 xxx.txt,都会被重写为 less -r xxx.txt,也就是原来的命令作为别名的后缀出现。上面这个 alias 的作用是当我们要输出某个 txt 的文件内容时,只要输入 xxx.txt 就可以了,无需更多的命令。

全局 alias 就更强大了,它会对整个命令进行匹配和替换,举个栗子:

alias -g L='| less'

以前如果想用 less 去查看一个文件需要写成 cat xxx | less,由于有了全局别名,现在只要写成 cat xxx L 即可。

如果输入命令 alias xxx L -r 它会被替换成 alias xxx | less -r。

相信读者已经能理解后缀 alias 和全局 alias 的用法,但请慎用,尤其是全局 alias,类似于 C 语言的宏定义,滥用可能会带来一些危险,建议先看看大神是怎么用的,这里面提供了很多 alias,如果不是必要,我建议尽量避免自行添加。

查看定义

如果只是想查看别名或者函数的定义,可以使用 which 命令:

但如果拿到别人的配置脚本,想自行定制。显然只知道定义是不够的,还得知道这个 alias 或者函数是在哪个文件里被定义的,这样才好去修改,此时建议使用我配置的 bswhich 命令:

这是因为查找 alias 定义位置和函数定义位置的方法还不一样,完整的写法是:

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 的路径,并且保存在数据中,以后我们可以直接输入 j + 关键字,从而避免频繁的 cd。

终端命令自动补全

输入快捷键 Ctrl + E 可以根据当前提示快速补全,快捷键 ; 可以补全并执行

终端 Finder 模拟器:r

系统的 Finder 其实并没那么好用,最大的问题在于没法和 Shell 有效的交互,比如复制移动文件、在当前文件夹位置打开终端都很不方便。

作为程序员,我推荐使用 Ranger 来浏览文件目录,它是一个使用 Vim 键位映射的文件管理工具。

使用快捷键 r 来打开 ranger,它的完整定义是:alias r='source ranger',这样做的好处在于当 Ranger 中目录发生变化时,可以改变外部 Shell 的路径。

在 Ranger 中,使用 j/k 来上下移动光标,h/l 来进行目录的前进和后退。

常用的操作有:

zh:切换是否显示系统隐藏文件,按一次打开,再按一次关闭
x:安全删除文件(放入垃圾箱中而不是 rm)
yy:复制,dd:剪贴,pp:粘贴,空格键多选文件
gh:进入用户目录($HOME)
yn:复制文件名,yd 复制文件夹名,yp 复制完整路径名
:j:和 autojump 一样,输入要跳转的地方
Ctrl + f:利用 fzf 搜索文件
f:当前目录内过滤文件名
du:查看当前目录内各文件夹大小
oo:在 Finder 中打开,op 或回车键:使用系统默认的程序打开,oc:使用 VSCode 打开(如果已经有 VSCode 进程,为了加快速度,则使用已存在的)
m:添加书签,um:选择要删除的书签,```:展示书签

fzf:模糊搜索神器

fzf 是一个模糊搜索神器,^t 是特定语义下的补全快捷键,^i 是默认快捷键,很少用到:

输入 kill 然后按下 ^t 键,就会打开 fzf 补全界面,通过输入进程名来获取到 PID
类似的还有输入 ssh、export、unset、unlias 等命令
按下 alt + c 可以列出当前目录下的文件夹,并快速进入
按下 ^g,会自动补全 autojump 的路径列表
按下 ^r 进入命令历史模式,此时也会自动打开 fzf 补全界面,自动补全命令
注意此时的补全并不会自动执行,只会把命令粘贴到命令行中,如果想要按下回车后自动执行,可以用快捷键 ^x^r 来触发

fzf 甚至还支持为自定义的命令添加补全,具体做法可以参考:Examples (completion)

第 6.7 章 常用命令推荐

bsfn:查找文件名

如果你想查找文件夹内的某个文件,可以使用 find 命令,但默认的 find 命令并不支持表达,所以我在 personalized.sh文件中封装了 bsfn 函数,它接受一个参数,可以精确匹配,也可以写正则表达式:

比如这里我们搜索所有以 BBA 开头,中间字符不限,以 Plugin 结尾的文件。

bsgrep:查找内容

简单的装了 grep,如果不加路径,则表示在当前目录下递归搜索。

bsfilename: 获取文件名

这个命令可以从完整的文件路径中获取不带后缀的文件名,比如

bsfilename ~/Desktop/test.py
# 输出结果: test

bsof: 检查系统端口占用

可以通过系统的 lsof -i:port 来检查哪个程序占用了 port 端口,但有时候我们不想记参数,或者想查找某个程序占用了哪些端口,此时可以使用 bsof。

比如查看 redis 进程占用了哪些端口,可以输入 bsof redis,查看哪些进程占用了 80 端口可以输入 bsof :80,如下图所示:

bszip: 压缩文件

这个命令可以快速压缩文件,用法 bszip path_to_file,它会读取要压缩的文件(夹)名,然后在当前目录生成同名的 zip 文件

bswhich:查看定义

如果拿到别人的配置脚本,想自行定制。显然只知道定义是不够的,还得知道这个 alias 或者函数是在哪个文件里被定义的,这样才好去修改,此时建议使用我配置的 bswhich 命令:

bswhich ip
bswhich gg

bssize:查看文件和文件夹大小

bssize 后面的参数可以是文件名,表示查看这个文件的大小。也可以是文件夹名,表示查看文件夹大小和文件夹内各子目录的大小。

bssize . 表示查看当前目录大小和子目录大小,bssize / 表示查看系统磁盘的使用情况。具体效果如图所示

c:使用 VSCode 编辑

首先需要集成 VSCode 的命令行工具,步骤可以参考这个链接

这个命令有三种用法:

如果不加任何参数,会使用 VSCode 打开当前文件夹
如果参数所代表的文件或文件夹存在,会用 VSCode 打开指定的文件夹
如果参数代表的文件不存在,会用 autojump 打开指定路径并使用 VSCode 编辑

ow:快速打开 xcode 工程

自动查找当前目录下的 xcworkspace 和 xcodeproj 文件并打开, 也可以指定路径。

proxy:展示和切换系统代理

如果想使用 Charles 抓包,则输入 p on 即可将系统的 HTTP 和 HTTPS 代理设置为 127.0.0.1:8888

如果想使用 Shadowsocks 科学上网,则输入 p g 即可将系统的 socks 代理设置为 localhost:14179,需要自行修改端口号

如果不想使用代理,输入 p off 可以禁用所有代理,恢复默认设置。

输入 p s 可以查看当前的系统代理:

ppjson:终端 json 格式化

用法:

echo '{"hello": "world"}' | ppjson

效果:

encode64 和 urltool

这几个小命令可以快速实现一些编码和解码工作:

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
如果是看输出的指定行数,比如第 1、3、7 行,可以用 cat xxx R 1 3 7, 其中 R 被全局重命名为 | row
如果要看某个输出的某几列,比如倒数第一列,可以用 cat xxx C -1,其中 C 被全局重命名为 | column
如果要在 less 中查看某个超长的输出,可以用 cat xxx L,其中 L 被全局重命名为 | L
如果要忽略某条命令的报错,可以用 command NE,其中 NE 被全局重命名为 2> /dev/null
如果要某个命令完全不输出内容,可以用 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

©著作权归作者所有,转载或内容合作请联系作者
禁止转载,如需转载请通过简信或评论联系作者。
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,445评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,889评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,047评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,760评论 1 276
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,745评论 5 367
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,638评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,011评论 3 398
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,669评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,923评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,655评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,740评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,406评论 4 320
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,995评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,961评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,197评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,023评论 2 350
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,483评论 2 342