2021-10-01

简单的 Git

Git 是一个文件历史版本控制工具,可以理解为每次的改动都有备份,改砸了不要紧。并且 Git 支持多人同时协作,适合小组分工同时写代码的场景,不用担心你改着改着没保存被别人传上来的覆盖了的情况,出了 Bug 也能找到是谁的锅。开源项目也可以通过将 Git 仓库公开的方式让别人取得源代码,Git 也提供了让别人对这个项目代码提交贡献的机制。

本文共计约 7000 字,仔细阅读需要 20 分钟左右的时间。文章最后会通过很简单的一段话概括这篇教程的内容。

1. 仓库 (Repository)

一个 Git 仓库 (repository) 对应着一个目录,这个目录就是存放项目代码的地方。在这个目录下的改动可以被记录、备份。如果要新建一个本地仓库,可以使用

1
git init

命令。该命令会让当前所在的目录成为一个 Git 仓库。

如果要将远程的 Git 仓库(例如某个开源项目的代码)下载到本地,可以使用

1
git clone <uri>

命令。这时会在当前目录下新建一个以项目名命名的目录,这个目录下就是远程的 Git 仓库下载到本地的内容。自然,这些内容也包括了远程仓库完整的历史,意味着你可以在下载后查看或恢复到之前的某个版本。

如果你只是想取得远程仓库当前最新的代码,而不关心历史,可以使用--depth参数,指定要下载的历史记录条数,例如

1
git clone <uri> --depth 1

这样下载下来的远程仓库仅保留最近的一次提交历史记录,称为 shallow clone,可以减少下载时传输的数据量。

Git 是如何存储改动的历史记录的呢?如果某个目录对应一个 Git 仓库,那么该目录下会有一个隐藏的.git文件夹,这个文件夹里就存放着这个仓库和 Git 有关的全部内容,包括历史记录、分支等。如果删除了.git文件夹,那么这些历史记录等均会被删除,这个目录就会变成普通的目录,不再是 Git 仓库。

2. 提交 (Commit)

如果你只是用 Git 来下载代码,那上面一节就够用了。Git 的一个强大之处在于它可以备份改动的历史记录,每次改动对应 Git 中的一次提交 (commit) 。如果你在 Git 仓库的工作目录 (working directory) 下编写并修改代码,怎样才能让 Git 执行提交操作,记录下这一次的修改呢?

2.1 暂存 (Stage)

首先要做的是暂存 (stage) ,暂存一般用来标记即将在下一次提交中的文件改动。处于未追踪状态 (untracked) 、已修改状态 (modified) 、已删除状态 (deleted) 的文件都可以暂存。

  • 未追踪状态 (untracked) :工作目录下原本不在 Git 仓库内的文件,例如新建的文件
  • 已修改状态 (modified) :已经在工作目录下被修改的 Git 仓库内的文件
  • 已删除状态 (deleted) :已经在工作目录下被删除的 Git 仓库内的文件

可以用

1
git status

命令查看当前仓库中各个文件的状态,例如你在某个 Git 仓库的工作目录下新建一个README.md文件,修改已有的material/base.html文件,并删除已有的package-lock.json文件,则上述命令的结果应显示如下:

1
2
3
4
5
6
7
8
9
Changes not staged for commit:
  (use "git add/rm <file>..." to update what will be committed)
  (use "git restore <file>..." to discard changes in working directory)
    modified:   material/base.html
    deleted:    package-lock.json

Untracked files:
  (use "git add <file>..." to include in what will be committed)
    README.md

Changes not staged for commit,说明这些改动没有暂存。根据上面的提示,可以用

1
git add <file>

命令将文件名为<file>的文件改动暂存。

对于已删除状态的文件,既可以使用git add,也可以使用git rm命令暂存这一改动。git rm除了标记已删除的文件为暂存外,还可以直接删除工作目录下的文件并标记这一改动为暂存。

暂存后,如果再次用git status命令查看状态,结果应显示如下:

1
2
3
4
5
Changes to be committed:
  (use "git restore --staged <file>..." to unstage)
    new file:   1.md
    modified:   material/base.html
    deleted:    package-lock.json

2.2 提交 (Commit)

当你确定所有准备提交的改动都已经暂存时,就可以通过

1
git commit -m <msg>

来进行提交。msg用来记录这次改动的简要内容,以备查阅。如果这是你的第一次提交,Git 会拒绝这一次提交,并显示 Please tell me who you are。这是因为每次提交附带的信息除了有msg之外,还有这次提交的作者。可以通过

1
2
git config --global user.name <name>
git config --global user.email <email>

来设置你的姓名和联系邮箱,这些信息都会附带到你之后进行的所有提交中。

整个暂存 - 提交流程如下图所示:

暂存 - 提交流程

如果你提交之后,发现还有东西落下了,或者提交了错误的内容,或者发现提交的信息写错了,想要修正上一次的提交,可以在提交后接着添加/修改/删除对应内容,并暂存后,再使用

1
git commit --amend

对上一次的提交进行修正。这个命令并不会产生新的提交。

2.3 恢复 (Restore)

既然 Git 可以备份历史记录,那它一定有恢复文件改动的功能。如果想要取消某个文件改动的暂存,可以使用

1
git restore --staged <file>

命令。如果想要接着恢复已修改或已删除的文件到上一次提交时的版本,可以使用

1
git restore <file>

命令。使用这一命令时,所有对该文件的改动均恢复原状,且这一操作不可撤销,因此要谨慎使用该命令。对于新建的处于未追踪状态的文件,直接在工作目录下删除该文件,即可恢复至上一次提交的状态(因为上一次提交中就没有这个文件)。

上面说的整个恢复的流程如下图所示:

恢复流程

3. 分支 (Branch)

难点开始了!Git 的另一个强大的地方是支持非线性的工作流,这为之后要说的多人同时协作功能提供了支撑。例如,你正在为某个项目写一个新的 Feature,但是突然发现了一个紧急的 bug 要修复,需要先回滚到原来的版本,修复 bug 并发布一个紧急的更新。之后要再回去接着写这个 Feature,并在下一次发布这个 Feature 时也包括修复上一个 Bug 的代码。这就是一个非线性的工作流。

非线性的工作流通过 Git 的分支 (branch) 实现,Git 仓库初始时默认有一个 master 分支。如果要理解分支,需要先理解 Git 存储提交的历史记录的数据结构。

3.1 历史 (History)

Git 通过树来记录提交历史 (History) ,每一次提交对应树上的一个节点,每一次提交都有一个(概率意义上可以视为)唯一的十六进制 SHA-1 校验和,由这次提交的改动计算得到,用于唯一标识这个提交。每一次提交一般会有一个指针指向上一次的提交。例如下面是某个仓库的提交历史记录:

历史记录

HEAD 指针表示现在在 master 分支的 8e27fb 这个提交上。在 master 分支上进行下一次提交时,该提交会指向指向 8e27fb,并且 HEAD 和 master 指针会重新指向下一次的提交,即往前同步移动一格。

SHA-1 用十六进制表示有 40 位,写出来会很长,但是只要某个提交的前缀是唯一的,就可以通过这个前缀来表示这个提交,例如上图中就用了 6 位 SHA-1 的前缀来表示。

如果要查看当前分支的提交历史,可以使用

1
git log

命令,按照提交的时间顺序查看当前分支的历史记录。如果要查看分支的层级结构和所有分支的历史记录,类似本文中图片的形式,可以使用

1
git log --all --graph

3.2 分支 (Branch)

上面的图演示了简单的线性情况。那么如何创建一个新的分支,引入非线性的历史呢?可以通过

1
git branch <branch_name>

创建一个名为<branch_name>的分支。这个新的分支初始指向当前的 HEAD 所指向的提交。例如在上图的基础上,通过git branch dev新建一个名为 dev 分支时,效果如下:

新建分支

注意,这时候你仍然在 master 分支上,因为 HEAD 指向的是 master 分支,如果进行提交,新的提交仍然算在 master 分支下,HEAD 和 master 仍会同步移动。如果要切换分支,可以通过

1
git checkout <branch_name>

切换到名为<branch_name>的分支,例如通过git checkout dev,就可以让 HEAD 指向 dev 分支,切换到该分支。这样之后的提交会算在 dev 分支下。例如,如果切换到 dev 分支后,再连续进行两次提交,那么效果如下:

切换分支

3.2 合并 (Merge)

如果这时,你决定将 dev 分支中所做的修改发布到 master 分支,相当于将这两个分支合并,可以通过git checkout master切换到 master 分支,并在 master 分支上使用

1
git merge <branch_name>

将名为<branch_name>的分支合并到 master 分支。这里即git merge dev。这样,HEAD 指针和 master 分支将向前移动,直接指向 d87df2,没有新的提交产生。这种合并方式称为 fast-forward。

如果这时在 dev 分支使用git merge master,因为 dev 分支比 master 分支新,会提示 Already up to date,无法合并

换一种情况。假设 dev 分支仍然处在开发过程中,没有对外发布,但是之前对外发布的 master 分支这个版本中发现了一个重要的 bug,需要紧急修复,这时你通过git checkout master切换到 master 分支,进行修改并进行了 be8746 这次提交,发布了新的 master 版本。结果如下:

历史记录的分歧

这时就出现了历史记录的分歧 (divergent history) 。如果这时你决定要发布 dev 分支,把 dev 分支中引进的改动合并到 master 分支,可以和上面的情况一样使用git merge dev。如果运气好,没有文件冲突的话,Git 会自动创建一个合并提交 (merge commit) ,这个提交有两个指针,分别指向原来的 master 和 dev 分支,效果如下:

合并

如果运气不好的话,合并时会产生冲突。冲突主要由下面几种原因产生:

  • 两个分支中都修改了同一个文件的相同的部分(修改同一个文件的不同部分不一定产生冲突)
  • 两个分支中一个删除了某个文件,另一个接着对该文件进行修改
  • 两个分支中都添加了文件名相同但是内容不同的文件

这时就需要手动处理冲突了。例如,如果在 master 分支和 dev 分支都修改了 main.c 这个文件,出现冲突,则使用git merge dev命令时,会显示

1
2
3
Auto-merging main.c
CONFLICT (content): Merge conflict in main.c
Automatic merge failed; fix conflicts and then commit the result.

如果使用git status查看状态,会发现

1
2
3
4
5
6
7
8
On branch master
You have unmerged paths.
  (fix conflicts and run "git commit")
  (use "git merge --abort" to abort the merge)

Unmerged paths:
  (use "git add <file>..." to mark resolution)
    both modified:   main.c

这时,你需要手动编辑 main.c,或者借助图形化的工具来解决冲突,例如用 VSCode 打开此时的 main.c,效果如下:

手动解决冲突

可以看到,<<<<<<< HEAD=======之间是 master 分支改动的内容,而=======>>>>>>> dev之间是 dev 分支改动的内容。当你手动解决冲突后,需要删除这些 Git 额外引入的标记。

之后,和之前创建一次普通的提交一样,通过git add命令标记改动的文件 main.c 为暂存,并通过git commit命令执行这一次的合并提交。

合并后,如果不再需要 dev 分支,可以通过

1
git branch -d <branch_name>

删除分支。因为分支仅仅是一个指向提交的指针,删除 dev 分支后,对之前的历史记录不会产生影响。

3.3 变基 (Rebase)

最难的点来了!可以看到,在历史记录产生分歧时,合并会引入一个新的合并提交。但如果想让历史记录保持干净明了的线性,又想达到和合并分支相同的效果,该怎么办呢?如果还是以 3.2 节第一张图中的情况为例,这时可以在 master 分支上使用变基 (rebase)

1
git rebase <branch_name>

将当前分支的“基”指向<branch_name>分支。例如这里使用git rebase dev,就是将 master 分支的“基”(即 dev 和 master 分支开始分叉的 8e27fb 这次提交)指向 dev 分支(d87fd2 这次提交),效果如下,最终 master 分支的文件内容和 3.2 节中合并后的 master 分支的文件内容完全相同:

变基

可以看到,原先在 master 分支上的提交 ba8746 被“嫁接”到了 dev 分支上,并且这个提交的 SHA-1 值也被修改了,这是因为这次变基的机制是

  1. 变基前,先找到 dev 和 master 分支的公共祖先 (8e27fb)
  2. 计算当前的 master 分支从 8e27fb 开始的每次提交带来的改动,设 ba87468e27fb=Δ1
  3. 将这些改动依次应用到 dev 分支,形成新的提交,即 d87fd2+Δ1=912b64

如果是在 dev 分支上使用git rebase master,则 master 分支不变,最终 dev 分支的内容和上图中 master 分支的内容完全相同,所以还要进行额外的git checkout mastergit merge dev的操作。

从这个例子可以看到,变基通过修改提交内容,实现了线性的历史记录。变基也会存在冲突的情况。如果合并存在冲突,那么对应的变基也一定存在冲突。解决冲突的过程和 3.2 中相同,当你手动修改文件解决冲突后,通过git add标记暂存,接下来通过

1
git rebase --continue

完成这次的变基操作。解决变基冲突的原理其实是通过git commit --amend修改了原先 dev 分支上产生冲突的某次提交,并不会引入新的提交。

因为合并和变基的最终效果相同,但是反映的历史记录不同,所以可以根据自己的偏好进行选择。如果不想让历史中有一堆的合并提交,保持线性,就使用变基。如果想要更方便的进行代码合并操作并解决冲突,或想要将合并反映到历史记录中,就使用合并。

3.4 重置 (Reset)

2.3 中说的的恢复 (restore) 和修正提交 (amend) ,其范围仅限于一个提交内。如果你想要查看之前的某次提交时的文件内容,即只让 HEAD 指向之前的某次提交,同样可以使用 checkout 命令:

1
git checkout <commit_sha_1>

只不过这里的参数是某次提交的 SHA-1 值,可以使用前缀表示。如果要让当前分支的内容撤销到之前的某次提交,即让 HEAD 和当前分支的指针都指向之前的某次提交,可以使用重置 (reset) 命令:

1
git reset [--soft|--hard] <commit_sha_1>
  • 默认:删除比<commit_sha_1>新的提交历史,但保留新的提交的更改,不暂存
  • --soft:删除比<commit_sha_1>新的提交历史,但保留新的提交的更改,并暂存
  • --hard:删除比<commit_sha_1>新的提交历史,不保留新的提交的更改

除了通过<commit_sha_1>这种表示方法指定某次提交之外,还可以通过HEAD~x这种表示法,其中~x表示从 HEAD 开始向前数的第 x 次提交。

使用重置可以进行多个提交的合并 (squash) ,如果你想将前三个提交合并成一个提交,可以通过git reset --soft HEAD~3,然后进行git commit操作。

4. 远程 (Remote)

前三节说了 Git 在本地的操作,那如何通过 Git 进行在线的多人协作呢?这就需要为本地 Git 仓库设置一个或多个远程 (remote) 仓库。在第 1 节中,如果通过

1
git clone <uri>

命令下载某个远程仓库到本地,Git 会自动设置一个名为 origin 的远程仓库,指向该<uri>。如果想要手动设置远程仓库,可以通过

1
git remote add <remote_name> <uri>

设置一个名为<remote_name>的指向<uri>的远程仓库。如果要删除某个远程仓库,可通过

1
git remote rm <remote_name>

如果要引用远程仓库的内容,例如引用 origin 的 master 分支,则用 origin/master 表示,和本地的 master 加以区别。当添加完远程仓库后,可以使用

1
git fetch <remote_name>

来取得或更新远程仓库的内容。如果不指定<remote_name>,则默认更新名为 origin 的远程仓库。

4.1 跟踪 (Track)

设置了远程仓库之后,为了让 Git 知道哪些内容是需要和远程仓库进行同步的,就需要设置本地的哪个分支跟踪 (track) 远程的哪个分支,这样这两个分支之间就可以通过 4.2 节和 4.3 节中提到的命令进行同步。

如果使用了git clone命令,则 Git 会自动将本地的 master 分支跟踪 origin/master 分支。如果是通过手动方式设置的远程仓库,或者在git clone时除了 master 分支外,还想让本地的其他分支跟踪远程的其他分支,可以通过

1
git branch -u <remote_name>/<branch_name>

让当前所在的分支跟踪<remote_name>远程的<branch_name>分支。这里的 u 表示 set upstream,即远程分支是本地分支的上游。

手动设置跟踪时,应确保已经通过git fetch命令取得远程仓库的内容,否则会提示远程仓库的该分支不存在。

更方便的,如果远程已经有一个名为<branch_name>的分支,但是本地没有,可以直接通过

1
git checkout <branch_name>

Git 会在本地自动创建对应的该分支,追踪远程的分支。

4.2 拉取 (Pull)

假设你通过git clone的方式下载了一个远程仓库,Git 自动帮你设置了名为 origin 的远程,并建立了 master 分支跟踪 origin/master 分支。但是远程仓库的拥有者在你下载后,又在 master 分支更新了两次提交 (5b4512, ff9091) ,这时你该如何同步远程仓库的这两个更新到本地呢?

如果不用新的知识,首先可以想到通过git fetchgit fetch origin,更新 origin 的内容,这时如下图所示:

拉取内容

然后就可以通过git merge origin/master,让 master 分支和 origin/master 分支合并。因为你在下载后没有改动本地的 master 分支,因此合并过程采用 fast-forward 形式,最终 HEAD 和 master 都指向了 ff9091 这次提交,没有引入新的提交,完成更新。

假如你在远程仓库的拥有者对远程的 master 提交时,你在本地也对 master 分支做了一次提交 (c7b18d) ,如果这时要更新,也同样使用git merge origin/master,那情况和 3.2 节的合并完全相同。或者如果你想保留线性的历史,同样可以使用git rebase origin/master,那情况和 3.3 节的变基完全相同。如果有冲突,解决冲突的方法也相同。两种情况分别如下图所示:

拉取的两种方式

因为这种更新操作非常常用,因此 Git 提供了拉取 (pull) 命令简化上面的流程:

1
git pull [--merge|--rebase]

如果是git pullgit pull --merge,则 Git 会找到当前所在分支跟踪的远程分支<remote_name>/<branch_name>,然后进行

1
2
git fetch <remote_name>
git merge <remote_name>/<branch_name>

如果是git pull --rebase,则 Git 会进行

1
2
git fetch <remote_name>
git rebase <remote_name>/<branch_name>

如果在合并或变基过程中出现冲突,则会撤销这一操作,需要手动执行git fetchgit merge/rebase并解决冲突。

4.3 推送 (Push)

4.2 的第二个例子中,你已经在本地做了一次提交。更新后,如果你想要将你的提交发布到远程,就要使用推送 (push) 命令:

1
git push

这会让当前所在分支的新的改动推送到当前所在分支追踪到远程分支。这里即向远程上传这些新的提交的内容,并让 origin/master 指向当前的 master。

如果在 4.2 的第二个例子中,没有更新远程的内容,直接git push的话,Git 会拒绝这一操作,因为这会丢弃你没有来得及更新的别人已经推送过的提交,改写远程的历史。因此在git push前,通常要通过git pull确保更新了远程的内容,再推送。如果确定要强制推送,可以使用

1
git push --force

强制改写历史,这适用于不小心将密码等敏感信息推送到远程,想要删除的情况。如果你在上一次提交中不小心加入了敏感信息,且已经推送,则可以先通过 3.4 节中的git reset [--soft|--hard] HEAD~恢复到上次的提交,或通过 2.2 节中的git commit --amend修正上一次的提交,之后通过git push --force强制改写远程的历史。

git push除了推送提交之外,还可以新建和删除远程分支。可以通过

1
git push -u <remote_name> <branch_name>

<remote_name>远程仓库新建一个名为<branch_name>的分支,分支的指向和本地的名为<branch_name>的分支指向相同,并同时设置本地的<branch_name>分支追踪远程的这一新建的分支。如果要删除远程的分支,可以通过

1
git push -d <remote_name> <branch_name>

删除<remote_name>远程仓库中的<branch_name>分支。

使用git push时,需要你对指定的远程仓库拥有“写”的权限。

4.4 GitHub

如何设置一个远程仓库,让多人同时协作呢?GitHub 就是一个常用的代码仓库托管平台。你可以在 GitHub 上创建自己的仓库,并邀请其他人,让他们拥有该仓库的“写”权限,这样他们就能向该仓库推送提交了。这些你邀请的人称为项目的维护者 (maintainer) 。

如果你的项目是开源项目,想接受各种人的提交,但又不想把写权限开放给任何人,不然会导致恶意提交,这该怎么办呢?GitHub 中引入了拉取请求 (pull request) 机制,别人可以复制 (fork) 一份你的代码,相当于新建了一个 Git 分支,然后他们就可以自由的修改他们 fork 的仓库,当他们决定要贡献时,就会向你提出拉取请求 (pull request) ,这时你就有权决定是否要将他们的分支和你当前的分支合并。如果你决定合并他们的分支,那他们会成为这个项目的贡献者 (contributor) 。

所以,如果你想在 GitHub 上为其他开源项目做贡献,一种方式是让他们邀请你加入仓库,给你“写”权限,直接成为项目的维护者,但是这种情况基本没有。另一种也是最常见的方式,就是你 fork 一份开源项目,修改后,再提出 pull request,如果他们对你的提交感到满意,会将你的分支合并,你就成为了这个项目的贡献者。

除了 GitHub 之外,GitLab、国内的 Gitee (码云) 代码托管平台也都能创建自己的远程仓库,也有类似的邀请和拉取请求机制。

5. 子模块 (Submodule)

现在,很多项目之间都有依赖关系,一个项目经常需要依赖另一个项目的代码。如果这两个项目都是用 Git 仓库存放代码,当另一个项目更新时,这个项目就需要先git pull另一个项目,然后再git push到这个项目,非常麻烦,而且也会带来存储空间上的冗余浪费。这可以通过 Git 的子模块 (submodule) 更简单的实现。

如果你的 Git 仓库需要用到另一个 Git 仓库内容,即让另一个 Git 仓库作为子模块,可以通过

1
git submodule add <uri>

来添加位于<uri>的 Git 仓库子模块。这相当于进行了一遍git clone <uri>,并且新建了一个 .gitmodules 文件,这个文件保存了当前仓库用到的各种子模块的信息。

如果你的 Git 仓库同时设置了远程仓库,想要通过git push提交时,子模块文件夹下的内容并不会提交,而只会以链接的方式表示,这有效的节省了远程仓库的存储空间。

如果你想下载一个具有子模块的仓库到本地,默认的git clone并不会顺带把子模块下载下来,这时你需要在下载后,通过

1
git submodule update --init [--recursive]

让 Git 读取 .gitmodules 文件,初始化并下载子模块。如果子模块中还引用了子模块,需要添加--recursive参数。如果你想在下载时就自动下载所有子模块,可以使用

1
git clone <uri> --recursive

代替git clonegit submodule update

总结

这篇教程,你从 Git 仓库 (repository) 开始,通过在目录下初始化 (init) 或下载 (clone) 远程的仓库搞到一个本地的 Git 仓库。然后你在本地 Git 仓库内通过暂存 (stage) 、提交 (commit) 的方式保存历史记录 (history) ,之后可以通过恢复 (restore) 或重置 (reset) 的方式恢复。历史记录可以出现分支 (branch) ,方便并行开发,但这就涉及到了分支的合并 (merge) 、变基 (rebase) 操作。而多人远程 (remote) 协作的基础就是这些分支的操作,你设置了本地分支追踪 (track) 对应的远程分支后,通过拉取 (fetch/pull) 将远程分支的更新带到本地分支,通过推送 (push) 将本地分支的更新带到远程分支,GitHub 等平台是建立远程仓库的好地方。远程仓库间可能存在依赖关系,你通过子模块 (submodule) 来引用其他的远程仓库,方便操作,节省空间。

这篇教程没有涵盖这些 Git 命令所有的用法。如需查看某个命令的全部详细用法,可以使用

1
git <command> --help