学 Git 的第一步并不是 Git,而是命令行。这一步之所以必须放在最前面,是因为 Git 的每一条命令都不接收「要操作哪个项目」这个参数,它依靠的是执行命令时所在的那个文件夹。所以在敲下第一个 git init 之前,首先要能随时回答「现在在哪里」这个问题。
这一篇记录的是进入 Git 之前需要掌握的十来条命令。它们看起来是十来个互不相关的小知识点,但是把它们逐条抄成一张表格并没有太大意义,真正值得写下来的是,这些命令几乎全都围绕着同一个东西,那就是 Shell 的「当前工作目录」。想通了这一点以后,. 是什么、.. 是什么、文件为什么建到了别的地方、rm -rf 到底危险在哪里,就不再是四个问题,而是同一个问题。
这些命令在「Linux 入门」那条线里也会讲到,而且讲得更细。重合的部分这里就不重复了,比如「为什么
clear清不掉历史记录」这类「这件事由谁负责」的问题,Linux 命令行第一章 里已经整理过。这一篇只按照学 Git 的需要来记录,重点是进入 Git 之前必须具备的那一点基本操作能力。
一、当前工作目录:命令行里唯一的「我在哪」
在图形界面里,「当前在哪个文件夹」这件事是看得见的,窗口的标题栏和地址栏上都写着,换一个窗口就换了一个位置。命令行里没有窗口这个概念,取而代之的是一个隐藏起来的状态,叫作当前工作目录(current working directory)。Shell 一直记着它,但默认不会显示出来。
把它打印出来的命令是 pwd:
pwd
# /home/user/projects/git-study
代码 1:pwd 是 print working directory 的缩写。
它输出的是一条绝对路径,从根目录 / 一直写到当前的位置,不依赖任何上下文。当前工作目录之所以重要,是因为后面几乎每一条命令都会把它当作默认的参数:
这段前面似乎丢了 pwd 的示例输出,正文直接从「它输出的是一条绝对路径」开始,读起来会断层。
- 不带参数的
ls,列出的是当前工作目录里的内容。 touch a.txt创建出来的文件,会放在当前工作目录里。git init初始化的仓库,就是当前工作目录。
换句话说,每条命令后面其实都跟着一个看不见的「在这里」。所以当命令的结果出乎意料时,首先应该敲的往往是 pwd。
二、ls:看这个位置里有什么
ls 是 list 的缩写,它会把当前工作目录里的内容列出来:
ls # 列当前位置
ls projects # 列 projects 这个子目录里的东西,人不动
ls projects/git-study # 往下钻两层,中间用 / 分隔
代码 2:ls 的三种常见用法。
第二行和第三行需要单独说明一下,ls 某个目录 只是查看那个目录里的内容,并不会进入那个目录。看完之后,pwd 的输出一个字都不会变。在命令行里,「查看」和「进入」是两条不同的命令,这和图形界面里双击一下就既能看到内容又能进入目录不一样,所以刚上手时很容易混淆。
-a:隐藏文件不是一种属性
ls -a
# . .. .git .gitignore README.md src
代码 3:-a 是 all 的意思,它会把隐藏的文件也一起列出来。
这里有一个很容易误解的地方。「隐藏文件」听起来像是系统给文件加上了一个隐藏标记,但实际上完全不是这样。在 Unix 里,所谓的隐藏文件,只是名字以 . 开头的文件而已。 文件系统并没有「隐藏」这个属性,只是 ls 默认会跳过这类名字,Shell 的通配符展开默认也不会匹配它们。所以「隐藏」纯粹是一种命名约定,任何人都能把一个文件隐藏起来,只要在它的名字前面加一个点就可以了。
这一点对 Git 来说特别重要,因为接下来要打交道的两个东西都以点开头,一个是 .git(整个仓库的全部数据),另一个是 .gitignore(忽略清单)。不加 -a 的 ls 永远看不到它们,于是很容易让人误以为这个文件夹里什么都没有。
另外,还要顺便记住输出开头的那两项,也就是 . 和 ..。它们并不是 ls 为了凑数而打印出来的符号,而是每个目录里真实存在的两个条目。. 指向这个目录本身,.. 指向它的上一层目录。下一节的 cd .. 用的就是它。
-l 与选项合并
-l(long)会把每一项展开成一行,显示权限、属主、大小和修改时间。几个短选项可以拼在一起写:
ls -la # 等价于 ls -l -a
代码 4:短选项合并。
ls -la 可能是学 Git 时用得最多的一条命令,因为它可以同时回答两个问题,一是这里有什么,二是这里面有没有 .git。
三、cd:换一个位置
cd 是 change directory 的缩写,它的作用是真正地改变当前工作目录,所以执行之后,pwd 的输出也会随之改变。
cd projects # 进入子目录(相对路径)
cd .. # 回退一层
cd /home/user # 直接跳到某个绝对位置
cd # 不带参数:回到家目录
cd - # 回到上一次待的地方
代码 5:cd 的五种写法。
前两行使用的是相对路径,它们的含义取决于当前所在的位置,同一条 cd .. 在不同的位置执行,会到达不同的地方。第三行使用的是绝对路径,它以 / 开头,无论在哪里执行,结果都一样。这个区别在 Git 里会反复出现,比如 git add . 里的那个点,就是一条相对路径。
cd .. 之所以能回到上一层,并不是因为 cd 认识 .. 这个特殊符号,而是因为上一节说的 .. 条目是真实存在的,cd 只是照常走了进去而已。同样的道理,cd . 也是合法的,只不过相当于原地不动;../.. 则表示往上退两层。
这里还有两个实用的小技巧。第一,Tab 键可以补全路径,只要敲出前几个字母,再按一下 Tab 就行了,这样既省事,又能顺便确认目录名没有打错。第二,如果路径里有空格,必须加上引号或者进行转义,否则 Shell 会按照空格把它拆成两个参数,命令也就无法知道这其实是同一个文件夹。
四、touch 与 mkdir:在这个位置上造东西
touch notes.md # 创建一个空文件
mkdir git-study # 创建一个目录
mkdir -p a/b/c # 一次把缺的层级全建出来
代码 6:创建文件与目录。
mkdir 是 make directory 的缩写,它的作用没有什么歧义。值得记住的是 -p(parents)选项。默认情况下,如果 a 不存在,mkdir a/b/c 会直接报错;加上 -p 之后,它会把中间缺少的目录一起创建出来,而且即使目标已经存在,也不会报错。
touch 则名不副实。它的本职工作其实并不是创建文件,而是更新文件的时间戳(touch 的意思就是「碰一下」)。只有当目标文件不存在时,它才会顺便创建一个空文件,这只是它的副作用,而不是它的主要功能。
补充:touch 在文件不存在时创建的是空文件,权限受 umask 影响,这点原文没提。
这一点带来了一个好消息和一个坏消息。好消息是,对已经存在的文件执行 touch,并不会清空它的内容,只会把修改时间更新为当前时间,所以不用担心手滑执行一次 touch,就把已经写好的内容弄没了。坏消息是,正因为文件已经存在时它什么都不改,所以无法根据它是否执行成功,来判断文件是不是新创建的,因为这两种情况下它都会静默地成功。
还有一个从 Windows 带过来的习惯需要改掉。.md、.txt 这些扩展名对 Unix 系统本身没有任何意义。扩展名只是文件名的一部分,系统并不会因为文件名里写了 .md,就把它当作 Markdown 文件来处理。真正在意扩展名的,是编辑器、Git 的某些配置以及使用者自己。所以 touch notes 和 touch notes.md 创建出来的是两个同样合法的文件,只不过后者能让工具(以及三个月后的你)更容易看懂它是什么。
五、rm:删,而且没有回收站
rm notes.md # 删一个文件
rm -r old-project # 递归删一个目录(连同里面所有东西)
rm -rf old-project # 递归 + 强制,不问任何问题
rmdir empty-dir # 只能删空目录,非空就报错
代码 7:删除。⚠️ 这几条命令都没有确认的步骤。
首先必须记住的一点是,rm 并不会把文件移到回收站,而是直接删除目录项,默认既不会确认,也无法撤销。 在图形界面里删错了文件,还可以从回收站里找回来,但在这里并没有这一层保护。
把 -rf 拆开来看,它其实是两个独立的选项,分别去掉了一道阻拦:
| 选项 | 全称 | 它关掉了什么 |
|---|---|---|
-r | recursive | 默认 rm 拒绝删目录,-r 让它递归进去,把子目录和文件一并删掉 |
-f | force | 不提示、不报错,目标不存在也当成功 |
所以 -r 的作用是让 rm 能够删除目录,而 -f 的作用是让它不再给出任何提示。真正危险的是这两个选项凑在一起,因为 -r 扩大了删除的范围,而 -f 又把所有本来能拦一下的提示都关掉了。
真正会出事的场景,又恰好回到了第一节讲的「当前位置」上。rm -rf * 这条命令本身并没有指定任何具体的路径,它删除的是当前位置下的所有东西,所以它的破坏范围完全取决于敲下回车的那一刻,pwd 的值是什么。在项目的子目录里执行它,是在清理文件;在家目录里执行它,就是一场灾难,而这两次敲下的字符却一模一样。
而且通配符并不是由 rm 展开的,而是 Shell 在启动 rm 之前就已经替换好了。也就是说,命令里写的是 *,而 rm 收到的是一串已经列好的具体文件名。等到 rm 开始运行时,要删除哪些文件早就已经确定了,没有任何反悔的余地。
补一个坑:通配符不匹配任何文件时,默认 bash 会把 * 原样传给 rm,是否报错取决于 shell 选项。
所以从现在开始,最好养成两个习惯。第一,删除之前先执行 pwd,确认自己站在正确的位置上;第二,在执行危险的删除之前,先用 ls 带上同样的参数运行一遍,看清楚会匹配到哪些文件,再动手删除:
ls *.log # 先看会命中哪些
rm *.log # 确认无误再删
代码 8:把 ls 当作 rm 的预演。
对于 Git 来说,还有一条额外的红线,那就是 rm -rf .git 会把整个版本历史一次性全部删除。工作区里的文件看起来一个都没少,但是所有的提交记录、所有的分支、所有还能回退的版本都没有了。而这恰恰是 rm -rf 唯一无法挽回的东西,因为 Git 的后悔药本身就存放在 .git 里面。
六、两条不改变位置的辅助命令
clear 用来清空当前终端窗口里显示的内容。它只是让终端重新绘制一遍屏幕,命令历史一条都没有少,按 ↑ 键照样能翻出来(原因在 Linux 命令行第一章 里写过,显示内容由终端负责,命令历史由 Shell 负责)。它的快捷键是 Ctrl + L,比输入命令更快。
open . 的作用是在图形界面里打开当前工作目录,相当于在命令行和文件管理器之间搭了一座桥。不过这条命令在不同的平台上写法不同:
| 环境 | 命令 |
|---|---|
| macOS | open . |
| Windows(CMD / PowerShell) | start . |
| Linux 桌面 | xdg-open . |
| WSL | explorer.exe . |
需要注意末尾的那个 .,它就是第二节里说的代表当前目录的那个条目。这条命令并没有什么特别之处,它只是把「这里」交给图形界面去打开,所以它打开的是哪个窗口,同样由 pwd 决定。刚上手的时候,它其实是一个不错的验证手段。在命令行里创建完文件之后,用 open . 打开看一眼,比反复执行 ls 更让人放心。
小结
把这一章的十来条命令,按照「它和当前工作目录是什么关系」重新排列一遍,就只剩下三类:
| 命令 | 和「当前位置」的关系 |
|---|---|
pwd | 问它是什么 |
ls / ls -a / ls -l | 看它里面有什么 |
open . / start . | 把它交给图形界面打开 |
cd / cd .. / cd - | 改它 |
touch / mkdir | 在它里面造东西 |
rm / rm -rf | 在它里面删东西 |
clear | 唯一一条和它无关的(只管屏幕) |
可以看到,右边一整列都指向同一个隐含的参数。所以在命令行里,如果明明命令敲对了,却出现了奇怪的结果,十有八九并不是命令写错了,而是站错了位置。文件建到了上一层目录里、ls 看不到刚下载的文件、删掉了不该删的目录,这些问题全都出于同一个原因。
这也正是命令行速成必须放在 Git 之前的原因。Git 的命令同样不接收「要操作哪个项目」这个参数,git init 会把当前目录变成仓库,git status 报告的是当前仓库的情况,git add . 里的那个点指的也是当前目录。整套 Git 都建立在这个隐含的参数之上,只不过它比 rm 要客气一些,站错位置时,它一般只会提示 not a git repository。
下一章正式开始讲 Git,内容包括 init、status、add、commit,以及一次改动需要经过的三个阶段。