关于 Git 的学习笔记整理,这部分资料准备了很久,因为中文的教程大都过于简略,并且不涉及原理,学不明白,至于英文的文档也过于复杂,各种参数选项非常复杂。学习笔记的内容并不是由浅入深地介绍Git,而是一个梳理笔记,主要参考Git 官方手册

Git 必要配置

Git 的配置分成三层:

  1. 第一层是仓库级,在仓库目录下,文件为.git/config
  2. 第二层是用户级,在用户主目录下,文件为~/.gitconfig(主要修改的是用户级配置)
  3. 第三层是系统级,在安装目录下,安装时的选项会保存在这里

范围越小的配置文件优先级越高。除了这几个配置文件,还有一些可选的辅助配置文件,例如

  • 关于排除文件的规则配置 .gitignore
  • 关于文件格式的配置 .gitattributes

值得注意的,Git在每次操作时,会检查当前工作目录中实际存储的.gitignore以及其它配置文件,而不是Git保存的最新版本,即使我们修改了.gitignore,也不需要先单独提交.gitignore,它的规则会立即生效。

我们主要关注用户级配置文件,有如下常用的设置:

  • 添加用户名和邮箱,这是用于在提交信息上署名
1
2
git config --global user.name "xxxx"
git config --global user.email "xxxx@xx.com"
  • 将默认分支设置为 main(由于历史原因,默认为 master)
1
git config --global init.defaultBranch main
  • 指定编辑提交信息使用的编辑器,默认可能是 vim 或者 Nano(ctrl+x 退出)或者记事本
1
git config --global core.editor vim

如果在编辑提交信息时,突然想要取消,可以留下空的文本并退出编辑器,git 会拒绝没有提交信息的 commit。

除此之外,还有一些编码设置和换行符设置,一个 .gitignore 配置文件示例如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
[user]
name = xxx
email = xxx@xx.com
[init]
# 默认主分支名称
defaultBranch = main
[core]
# 默认编辑器
editor = vim
# 确保git正确显示中文信息
quotepath = false
# 在提交时将所有文本的换行符转换为LF,检出时不转换
autocrlf = input
# 检查文本的换行符,对于混合换行符的文件不允许提交
safecrlf = true
[i18n]
# 确保提交信息utf-8编码
commitEncoding = utf-8
[gui]
# 确保gui使用utf-8编码
encoding = utf-8

注意:

  • 即使这里进行了很多编码配置,实践中在Windows下仍然可能出现乱码问题,编码问题就是一个大坑,最好的办法只有一个:Git仓库中尽量不要使用中文文件名和提交信息。
  • 虽然在提交信息中是可以包括emoji的,有的规范也建议使用emoji,但是在某些环境下可能无法正常显示emoji,因此决定避免使用。

Git 基础操作

Git基础概念

在Git的逻辑层面上,主要的命令基于下列三个区域进行操作:

  • 工作目录:真实文件系统中的目录
  • index:缓冲区,暂存区,存储信息是下一次正式提交的草稿
  • 版本链表:由每一次的正式提交组成,通过HEAD和分支来指向和操作,链表体现了每一次提交之间的继承关系

其中index是介于当前工作目录和正式的版本链表之间的,发挥着重要作用。

正常的本地工作流程是:

  1. 在工作目录中新建或修改文件
  2. 将改动后的文件从工作目录缓存到 index
  3. 基于index的草稿生成一次正式提交,添加到版本链表,HEAD操作分支指向最新的提交

信息的传递顺序通常是:工作目录,index,HEAD。但是也可以反过来进行,这相当于撤销了新的改动,撤销操作在后续的笔记中,现在主要关注正向的操作。

创建项目(init)

git init 创建空仓库并初始化,当前位置会生成一个.git/文件夹,里面的信息就是Git仓库的一切,不建议直接读写.git/里面的文件,应当通过Git提供的命令进行操作。

加上--bare选项会创建裸的空仓库。如果在服务器搭建远程仓库,通常会选择裸仓库,裸仓库的名称习惯为 xxx.git ,此时的仓库相当于只是一个 .git/ 文件夹,没有工作目录,只有index和版本链表,例如

1
git init --bare xxx.git

注意:

  • 对于已经存在的仓库,再次执行git init没有任何效果,不会清空仓库或进行任何重置!
  • 如果希望从头再来,直接且有效的办法是移除整个.git/文件夹,然后执行git init
  • 如果在本地希望复制整个仓库到另一个位置,可以考虑只复制整个.git/文件夹到新的位置,然后检出
  • 如果在云服务器上创建裸仓库,则需要注意裸仓库及其子文件的用户权限,例如使用下面的命令将权限提供给git用户(当前为root用户)
    1
    2
    3
    git init --bare xxx.git

    chown git:git -R xxx.git

克隆仓库(clone)

git clone 用于从 URL 克隆现有仓库:

1
2
git clone https://github.com/libgit2/libgit2
git clone https://github.com/libgit2/libgit2 mylibgit

可以指定本地仓库的名称,缺省名称时使用与 URL 相同名称创建本地文件夹。 创建的本地仓库自动添加URL为远程仓库,并获取远程仓库的内容到本地。

如果克隆的仓库过大,考虑网络原因,可以选择舍弃 Git 的历史数据,使用 --depth 1 选项只会获取最新的提交版本。

从Github克隆仓库可能因为网络原因而失败,直接下载zip压缩包则更容易成功,但是下载的压缩包不含.git/,解压后的文件夹不是一个仓库,只包含最新版本的所有文件。

添加快照(add)

git add 命令可以添加未记录的或已记录但发生变化的文件到 index

1
git add <filename>

可以添加路径的所有项,也支持通配符

1
2
3
4
git add <path>
git add .

git add *

一个很重要的事实是:快照是在git add时就被添加到数据库中的,而非在git commit正式提交时添加到数据库。

如果无意间添加大文件和垃圾文件到数据库中,即使可以撤销 git add ,但是这些数据仍然会作为垃圾存在于数据库中,因此git add在提交时要慎重,不建议直接git add *。(Git实际上存在针对大文件进行额外支持的组件,这里不做讨论)

git add 只会记录当前文本的快照,如果在 git add 之后进行了修改,那么必须再次执行 git add 才会把最新的快照保存到数据库,并更新 index,index是下一次的正式提交的草稿。

查看状态(status)

git status 是非常常用的命令,可以查看当前的状态,工作目录的所有文件被 git 分成两类:

  • 未跟踪的项
  • 已跟踪的项

除此之外, .gitignore 文件所匹配的项会被 Git 直接忽略。状态信息主要包括:

  • 工作目录和 index 的对比
    • 未追踪的项
    • 已追踪的项,未提交到 index 的变化(修改,重命名,删除等)
  • index 和 HEAD 对应的提交版本的对比
    • 新追踪的项
    • 已追踪的项,提交到 index 的变化(修改,重命名,删除等)

如果 git status 信息过多,可以加选项 --short ( -s )得到更简短的状态信息,常见的信息例如

  • D 代表删除
  • A 代表添加
  • M 代表修改
  • 处于第一列对应 index 状态和 HEAD 的比较结果
  • 处于第二列对应工作目录中的当前状态和 index 的比较结果
  • ?? 代表未跟踪的项

例如

1
2
3
4
5
 M README
MM Rakefile
A lib/git.rb
M lib/simplegit.rb
?? LICENSE.txt

除了简明的信息,还可以使用 git diff 直接显示它们具体的差异

  • git diff 默认对比 index 和当前工作目录的差异
  • git diff --cached 对比 HEAD 指向的提交版本和 index 的差异

也可以加上文件名来显示指定文件的差异(index和工作目录,或HEAD指向的提交版本和index)

1
2
git diff filename
git diff --staged filename

注:通常情况下,--cached--staged 是等价的,--staged--cached的别名。但是对于某些命令,输出的结果略有不同,或者只支持其中一个选项。

移除已跟踪项(rm)

如果需要移除已经被 Git 纳入版本控制的文件,单纯在文件系统中执行 rm 是不够的:此时 index 显示文件存在(基于上一次的提交版本),工作目录显示文件不存在,需要将修改反馈给 index,执行git add 会记录这次变化行为到index

1
2
rm filename
git add filename

除此之外,Git 还提供了专门用于删除的命令:git rm,可以用于删除已跟踪的项,包括如下两类情景:

  • git rm删除工作目录和index中的目标文件,此时目标文件不在工作目录中,index记录该文件被删除

    1
    git rm filename

  • git rm --cached删除指定index中的目标文件,此时目标文件仍然存在于工作目录中,但是状态为未跟踪,index记录该文件被删除

    1
    git rm --cached filename

注:

  • git rm出于数据安全的考虑,要求被删除的项必须保持在HEAD指向的提交版本/index/工作目录中保持一致,不能存在任何的改动,否则必须加入强制选项才能删除
  • 如果只希望在工作目录中移除,但是不影响index,直接使用/bin/rm即可

移动已跟踪项(mv)

如果需要移动或重命名已经被 Git 纳入版本控制的文件,可以直接在文件系统中 mv ,这个操作会被 Git 检测到并视作移动,但是并没有更新到 index。

Git提供 git mv 命令可以在工作目录中移动或重命名的同时,立刻更新 index

1
git mv a b

等效于如下指令

1
2
3
mv a b
git rm a
git add b

提交快照(commit)

git commit 可以基于当前的index生成一次正式提交:一个 commit 对象,新的commit 对象会指向前一个快照的 commit 对象,将版本链表进一步延申, 然后HEAD指向的当前分支自动前移

1
git commit

commit 对象需要一个非空的 message,Git 会自动启动编辑器来输入 message,通常会启动vim或者记事本打开一个临时文件,临时文件中包括一些注释行,我们可以添加一些文本信息,然后退出编辑器。 如果退出时临时文件是空的或者仅含注释行,即提交信息为空,那么Git不会生成正式提交。

可以使用 -m 选项直接添加 message 字符串

1
git commit -m "add something"

可以使用 -a 选项在提交之前,对所有已追踪的项自动添加到 index,相当于自动跳过了 git add 过程,但是注意不会处理未被跟踪的项

1
git commit -a -m "add all files"

注意:

  • 如果index与上一次提交完全一样时,Git会拒绝这次无意义的提交,因为没有做出任何改动
  • 提交对象中除了记录我们手动输入的提交信息,还会记录提交的日期,提交的作者等信息

查看日志(log)

git log 可以查看提交日志,默认是按照时间顺序从新到旧的,呈现每一次提交的commit对象的详细信息(主要是日期,作者和提交信息),如果日志信息过多可以输入q退出。

有如下常用选项:

  • --oneline,简略输出,每一次提交的信息只占据一行,会省略日期
  • --graph,分支绘图,会通过字符绘制分支的状态,包括合并分叉等等
  • --all,展示所有分支的日志,否则只会呈现当前分支回溯的日志
  • -<n>,例如-3查看最近的 3 次提交
  • --since=2.weeks --since="2020-01-01" 可以在提交日志中按照时间或其他条件筛选
  • -S key_word 可以在提交日志中筛选:含有字符串key_word的添加或删除的提交,可以用来关注某个关键词的修改历史
  • -p filename 可以筛选指定文件的日志

例如

1
git log --oneline --graph --all -5

可以使用 HEAD,分支名,或者标签,也可以使用提交对象的哈希值来查看指定的一次提交

1
2
3
git log develop

git log 907e

清理工作目录(clean)

递归地删除当前目录下所有未被跟踪的文件,但是不包括那些被 .gitignore 匹配忽略的文件

1
git clean

这个操作非常危险,因此通常的配置都不允许直接执行,非常依赖额外的选项:

  • 使用 -f 选项,可以强制删除(危险)
  • 使用 -x 选项,则那些被.gitignore匹配忽略的文件也会被删除
  • 使用 -n 选项预演一遍,展示将要清理哪些文件,但是不会执行删除
  • 使用 -i 选项会交互式地确认删除哪些文件
1
2
3
4
5
6
7
git clean -f

git clean -x

git clean -n

git clean -i

git clean 命令主要用于在编译或某些误操作之后,在工作目录中产生了很多的垃圾文件和临时文件,此时可以直接清理并恢复工作目录。

如果需要删除的内容在目录中,需要加上-d选项,或者指定位置,例如

1
2
3
git clean -di

git clean -i .

归档(archive)

git archive可以将仓库归档导出为压缩包,压缩包格式支持tartgztar.gzzip,需要在Git仓库目录下操作。

例如,导出HEAD指向的版本

1
2
git archive --format=zip --output=output.zip HEAD
git archive --format=zip -o output.zip HEAD

可以指定导出某个分支的版本

1
git archive --format=tar.gz -o output.tar.gz branch_name

还可以指定导出版本对应的哈希值

1
git archive --format=zip -o output.zip commit_hash

补充:设置命令别名

git 可以给常用的命令以及选项添加别名(不含 git 前缀),直接以字符串形式进行替换。

例如

1
git config --global alias.ci commit

这个别名设置使得 ci 被直接替换为 commit ,下面两个命令等效

1
2
git ci ...
git commit ...

例如

1
git config --global alias.last 'log -1 HEAD'

这个别名设置使我们可以使用下面的语句直接查看最后一次提交

1
2
git last
git log -1 HEAD

这些别名实际会被存储到对应层级的配置文件中,例如

1
2
3
[alias]
trace = log --oneline --all --graph
now = status -s -b

Git 分支操作

分支基础

Git 的版本控制不是单一主线的,而是维持一个版本链表结构,如下图所示

特点如下:

  • 链表的每一个结点代表一次正式提交(commit对象),每一个正式提交结点都对应数据库中的一个正式的版本快照
  • 链表的每一个结点都有至少一个父结点(除了最初的结点),并且可能存在两个父结点(出现在分支合并时)
  • 链表最新的一头通常需要一个指针来确定头部位置,这个指针就是分支,例如默认分支就是 main 或 master
  • 链表可以有很多个分叉,链表的分叉也可能再次合并或分叉,每一个链表分叉的最新结点都通过一个分支定位,否则就处于游离状态
  • 当前所处的状态通过HEAD指针标记,它是一个二级指针,通常指向一个分支,可以自由切换指向任何一个存在的分支,并操作这个分支的移动
  • 提交会新建一个提交结点,并指向 HEAD 当前所间接指向的结点,然后 HEAD 指向的分支会前移到新结点上
  • 链表中可能存在游离的结点:既没有结点指向它,也没有分支指向它。这可能由版本回撤导致,此时这个结点代表的版本数据仍然保存在数据库中,但是版本历史以及正常操作中无法直接获取,需要高级的命令才可以恢复数据

分支管理(branch)

使用git branch命令可以创建或管理分支,创建的新分支会指向当前 HEAD 所间接指向的提交结点

1
git branch branch_name

加入-d选项可以删除指定分支

1
git branch -d branch_name

使用-m--move选项可以对分支进行重命名

1
git branch -m master main

如果希望列出所有的分支,直接使用 git branch 不带任何选项或参数,如果加上 -v 选项则会更详细地列出分支以及指向的提交结点信息

1
2
git branch
git branch -v

我们可以直接筛选出相对于当前分支,已经合并过来的分支,或尚未合并过来的分支

1
2
git branch --merged
git branch --no-merged

默认只显示本地分支,如果希望显示远程跟踪分支,需要 -r 选项,或者使用 -a 选项显示本地分支和远程跟踪分支

1
2
git branch -r
git branch -a

注意:在尚未进行一次本地提交时,是不存在本地分支和本地提交结点的,默认分支main或master都在第一次本地提交之后创建,在第一次提交之前是没有意义的

分支切换(switch)

git switch 也可以切换分支,相当于把 git checkout 的一部分功能专门拆分为一个单独命令,例如

1
git switch branch_name

-c 可以在切换时直接新建分支,如果分支已经存在,则操作失败, -C 选项则会新建分支,如果已存在直接重置分支

1
2
git switch -c branch_name
git switch -C branch_name

A->B 的分支切换具体内容是:

  • 首先 HEAD 从A指向另一个分支B
  • 然后基于分支指向的提交信息,尝试B初始化 index 和工作目录
  • 尝试A目前的未提交修改(包括在 index 的修改和在工作目录的修改),在新的 index 和工作目录中重演
  • 检查是否可以无冲突地重演这些修改,如果可以则切换;否则可能造成数据损失,拒绝切换
  • 换言之:切换前的 index 和工作目录并不要求是干净的,切换后的 index 和工作目录也不一定是干净的,顺利执行的切换会保留这些可重演的修改

注意这里的切换和 git reset --hard 相比是安全无冲突的,分支切换不会造成任何数据丢失(除非加上强制切换的参数选项):

  • 切换会保证:未提交的更改都可以在A->B->A再次切换回来之后完全恢复,不会存在负面效果(个人理解)
  • 对于暂存在 index 中或者仍然在工作目录中的修改,如果可以视作对切换之后状态的修改(类似于 rebase 的重演),那么仍然会保留,否则拒绝切换,几个例子:
    • 例如切换前后都有 a 文件,当前对 a 文件有未提交修改,切换后可以重演这里的修改行为,可以正常切换
    • 例如切换前有 a 文件,但是切换的目的地没有 a 文件,如果存在对 a 文件的未提交修改(无论是否暂存到 index),切换后无法重演这里的修改行为,那么切换会被拒绝
    • 例如切换前后都没有 a 文件,新建 a 文件(无论是否暂存到 index),可以正常切换
  • 即便切换是数据安全的,但是还是建议在切换前后保持 index 和工作目录是干净的。

注:git checkout在切换分支时也有相似的语法,实际上git switch就是拆分和继承了原本git checkout命令的一部分功能,现在更推荐使用git switch

  • 将 HEAD 指向另一个分支

    1
    git checkout branch_name

  • -b 可以在切换时直接新建分支,如果分支已经存在,则操作失败, -B 选项则会新建分支,如果已存在直接重置已存在的分支

    1
    2
    git checkout -b branch_name
    git checkout -B branch_name

分支合并(merge)

两个分支可以合并,将两条开发线的修改合并,假设 HEAD 指向的当前分支 main 指向结点 A,被合并的分支 test 指向结点 B,那么分支合并至少有以下几类情形:

  1. 如果A <- ... <- B,那么就会执行快进合并,将 main 直接前移到 test,也指向 B
  2. 如果B <- ... <- A,那么无事发生(视作 test 已经被 main 合并过了)
  3. 其他情形下,需要考虑 A 和 B 的最早公共祖先为 C 结点,进行三分合并,此时以 C 为基准,如果只有一方修改了,那么接收这个修改,如果两方都进行了修改(文件状态为both modified),视作冲突无法取舍
    1. 如果没有冲突,将会自动生成一个新结点 D,D 有两个父节点 A 和 B,分支 main 前移指向新的结点 D
    2. 如果存在冲突,那么就会进入冲突处理的特殊状态,冲突文件的状态为 unmerged ,可能因为两个分支都对它进行了修改(相比于公共祖先 C),需要手动修改这些冲突文件并清理标记,重新执行 git add ,此时 Git 认为冲突处理完成,再执行 git commit ,然后和无冲突时一样生成新的提交结点

分支合并的命令如下

1
2
git checkout master
git merge iss53

这会将 iss53 分支合并到当前的 master 分支,如果可以快进合并就不会生成新的提交结点,否则进行三方合并,生成新的提交结点并将 master 前移,而 iss53 不会移动,三方合并前后的示意图如下

如果在分支合并过程中发现了问题,可以取消这次合并,回到 merge 之前的状态

1
git merge --abort

分支变基(rebase)

分支变基是相对于分支合并的另一种处理方式,由于分支的产生和分支合并会导致整个链表结构非常复杂,而分支变基则会以另一种简单近似线性的方法维护整个链表,同时也可以将两条开发线上的修改合并。

仍然考虑三分合并的例子,假设 HEAD 指向的当前分支 master 指向结点 A,被合并的分支 experiment 指向结点 B,A 和 B 的最早公共祖先为 C 结点,将 experiment 变基到 master,就是将从 C 到 B 的历次修改在 A 的基础上重演一遍,得到一系列新的提交结点,最终结点为 B'(test 指针也相应移动到 B')

与合并不同,我们切换到 experiment 分支上进行变基操作

1
2
git checkout experiment
git rebase master

分支变基前后的示意图如下

注意到此时 master 还停留在原地,而 experiment 指向最前方的变基结果,还需要执行一次快进合并将 master 前移

1
2
git checkout master
git merge experiment

变基过程中同样也可能会出现冲突问题,解决方法为

  1. 修改冲突文件
  2. git add重新添加
  3. git merge --continue继续变基

注意:

  • 分支合并和变基最终结点的结果都是一样的,只不过链表和提交历史不一样了
  • 如果一直采用变基而不是合并,那么在版本链表所有的分支都会指向一条主线的各个结点
  • 变基后,原本 C 到 B 的那些结点不会被删除,但是通过正常方法无法访问,需要特殊的技巧

标签(tag)

标签包括两类:

  1. 第一类是轻量标签,它实质上相当于一个不可变的分支,指向某一个 commit 对象。创建轻量标签

    1
    git tag v1.4-lw

  2. 第二类是附注标签,它实质上一个独立的 tag 对象,它需要自带 message,指向某一个 commit 对象,或者其他对象。创建附注标签,需要-a选项,可以使用-m附带 message,否则 Git 会启动编辑器

    1
    git tag -a v1.4 -m "version 1.4"

打标签在缺省时自动针对最近的一次提交,当然也可以使用哈希值指定历史上的任意一次提交

1
git tag v0.0 907e

查看所有标签,支持通配符筛选,不区分两类标签

1
2
3
git tag

git tag -l "v1.8.5*"

查看标签的实质

1
git show v1.0

对于轻量标签,会展示指向的 commit 对象信息;对于附注标签则会展示标签对象自身的信息,包括附加的 message

删除标签

1
git tag -d v1.0-lw

注:

  • 附注标签会向数据库中添加一个对象,而轻量标签不会
  • 虽然两类标签本质上不同,但是主要的针对标签的操作并不会区分它们;
  • git log通常也可以查看到标签信息