构建高质量软件:持续集成与持续交付系统实践
上QQ阅读APP看书,第一时间看更新

3.3 Git与本地仓库

在3.2节中,我们了解了Git文件状态的生命周期,探究了Git中对象之间的关系,本节就来学习Git的一些常用命令和操作,尽管已有很多集成开发环境对Git命令进行了可视化集成,但是掌握这些常用命令的使用方法和实现细节对我们理解Git会有很大的帮助。

3.3.1 add与commit命令

add命令主要用于将变更提交至暂存区,为下一步的commit操作做准备,常见的add命令有如下几种写法。

  • 将某个文件提交至暂存区:git add文件名。
  • 将当前目录下的所有变更批量提交至暂存区:git add .(其中,“.”代表当前目录)。
  • 将当前目录及子目录下的所有变更提交至暂存区:git add --all。
  • 将当前目录及子目录下的所有变更提交至暂存区:git add -A(同上一个命令)。

commit命令主要用于将暂存区中的变更提交至本地仓库(已提交区),常见的commit命令有如下几种写法。

  • 将某个指定文件(变更已被提交至暂存区)提交至本地仓库:git commit文件名--message “comments”。
  • 将某个指定文件(变更已被提交至暂存区)提交至本地仓库:git commit文件名-m “comments”。
  • 将工作目录中的变更提交至暂存区,并从暂存区提交至本地仓库:git commit -am “comments”(如果是新的文件则必须先执行“git add文件”命令)。
  • 修改最近一次的提交,并将所有已提交至暂存区的变更都提交至本地仓库,具体命令为:git commit --amend -m “comments”,请看下面的示例代码。
# 批量touch三个空的文件。
> touch {a.txt,b.txt,c.txt}
# 将a.txt文件提交至暂存区。
> git add a.txt
# 将a.txt文件的变更提交至本地仓库。
> git commit -m "commit a.txt"
# 检查当前文件树对象,可以得知只有a.txt文件提交到了本地仓库。
> git cat-file -p 65a457425a679cbe9adf0d2741785d3ceabb44a7
100644 blob e69de29bb2d1d6434b8b29ae775ad8c2e48c5391    a.txt
# 很明显,这里遗漏了对b.txt和c.txt这两个文件的提交操作,当然也可以选择再执行一次提交操作,但是这样就会在提交日志中看到两次提交记录,能否对a.txt文件的提交操作稍作修改,在不增加提交记录的前提下将b.txt和c.txt也提交至本地仓库呢?使用“--amend”参数就可以做到这一点。
# 先执行add命令。
> git add --all
# 再执行commit命令,且使用“--amend”参数。
> git commit --amend -m "initial commit"
# 查看提交记录,只有一次提交,不仅修改了comments,而且也提交了b.txt和c.txt。
> git log
commit 5c14f4d519c4537941ab5848c5db36b352e75c26
Author: Alex Wang <alex@wangwenjun.com>
Date:   Thu Feb 18 19:35:55 2021 -0800
    initial commit

3.3.2 log命令

log命令主要用于查看历史提交记录,也是使用较多的命令之一。目前已有大量集成开发环境都对该命令做了很好地可视化集成,因此使用起来比较方便,log命令的常用方法包含如下几种形式。

  • 查看所有的历史提交记录:git log --all。
  • 查看最近几次的历史提交记录:git log -N(N∈Z)。
  • 只查看某个作者的历史提交记录:git log --committer=‘Alex Wang’或git log --author=‘Alex Wang’。
  • 查看几天之前的历史提交记录:git log --after 2.days.ago。
  • 查看给定时间区间的历史提交记录:git log --after "2021-02-01" --before "2021-02-28"。
  • 查看历史提交记录的同时列出详细的变更明细:git log -p。
  • 查看历史提交记录中每次提交的摘要和统计:git log --stat。
  • 精简模式输出提交记录:git log --oneline。
  • ACSII图形化输出提交记录(在多分支下比较有用):git log --graph。
  • 自定义提交记录的输出格式: git log --graph --pretty=format:"Commit Hash: %H, Author: %aN, Date: %aD"。
  • 自定义输出的格式和字体的样式:git log --all --graph --pretty=format:'%Cred%h%Creset -%C(yellow)%d%Creset %s %Cgreen(%ci) %C(bold blue)<%an>%Creset'。
  • 只输出某个文件的变更历史记录:git log <文件名>。

Git对日志的输出形式包含多种样式,甚至还提供了一些工具专门针对Git的日志格式进行图形化输出,比如Gitk。

3.3.3 diff与blame命令

diff命令主要用于对比两个提交之间的不同之处,还可以用于对比两个不同分支之间的不同之处(3.3.4节将有详细介绍),下面来看一个简单的例子。

# 创建一个文本文件,然后写入三行内容。
> echo -e "hello\nworld\ni am git" >diff.txt
# 将diff.txt文件提交至本地仓库。
> git add diff.txt
> git commit -m "create the diff.txt file"
[master 7e955d3] create the diff.txt file
 1 file changed, 3 insertions(+)
 create mode 100644 diff.txt
-------删除该文件的第二行内容,并进行提交(此处省略了具体的操作步骤)。
-------新增一行内容,并再次进行提交(此处省略了具体的操作步骤)。
# 至此,diff.txt文件在本地仓库中共存在三次提交。
> git log diff.txt
commit e382906aaca914e62240379711e18492817f159b
Author: Alex Wang <alex@wangwenjun.com>
Date:   Thu Feb 18 23:29:23 2021 -0800

    add the new line

commit 067294b815a04a4d7494281f7e88a8cc6f126a9d
Author: Alex Wang <alex@wangwenjun.com>
Date:   Thu Feb 18 23:28:52 2021 -0800

    delete the line two

commit 7e955d32f931b13b7055dd5d7c1b3b2a4f2cc9e4
Author: Alex Wang <alex@wangwenjun.com>
Date:   Thu Feb 18 23:24:33 2021 -0800

    create the diff.txt file

如果想要对比提交编号7e955d和e38290之间发生的变更,则可以使用diff命令,下面是diff命令的具体使用示例。

#对比两次不同提交之间的不同之处。
> git diff 7e955d e38290
diff --git a/diff.txt b/diff.txt
index b0b46b7..ed6ade2 100644
--- a/diff.txt
+++ b/diff.txt
@@ -1,3 +1,3 @@
 hello
-world
 i am git
+new line

diff命令可用于对比两次不同提交之间发生的变化:删除了world,增加了new line。虽然diff可以很好地帮助我们对比两次提交之间的不同之处,但是不能用于找出是哪个提交者进行的提交和改动,该需求需要借助于blame命令进行查看。

> git blame diff.txt
7e955d32 (Alex Wang 2021-02-18 23:24:33 -0800 1) hello
7e955d32 (Alex Wang 2021-02-18 23:24:33 -0800 2) i am git
e382906a (Alex Wang 2021-02-18 23:29:23 -0800 3) new line

关于diff命令和blame命令就简单介绍这么多,两者还提供了其他大量参数,大家可以通过阅读官方文档获得更多帮助。

3.3.4 Git的分支及操作

无论有没有创建分支,在利用Git进行版本管理的目录中,每一次的提交实际上都是基于一个分支进行的,默认情况下,这个分支是master分支(GitHub远程仓库原本默认的分支也名为master,2020年改名为main分支)。

#以下命令将会列出当前版本仓库的所有分支,其中,第三个命令还会列出远程仓库的分支。
> git branch
> git branch --list
> git branch --list --all
* master

第一个命令与第二个命令是等价的,两者都是用于列出当前本地仓库的所有分支,加入“--all”参数后,本地仓库和远程仓库的所有分支都会列出,由于目前仅有一个分支,且未与远程仓库建立绑定关系,所以只有一个称为master的分支。在列出的分支列表中,若前面带“*”号,则表示当前正在master分支中进行操作。

在开始学习如何创建分支之前,我们首先需要思考一个问题:为什么要有分支?想象一个场景:假设当前Git仓库master分支的最后一次提交为c03f79,我们需要对当前及其所有的历史变更记录进行打包,并将它们部署在某个运行环境中,然后继续下一阶段的开发工作。如果软件在发布后测试出了缺陷,则需要对它进行修复。如果继续在主线(master)分支上修复问题,则势必会引入最近的一些提交,而这些提交并未完成测试,甚至连功能也不是完整的,这将会导致部署的又一次失败。

Git仓库中的分支可以很好地解决上述场景中描述的冲突问题,比如,基于最近一次的提交创建一个新的分支(dev),用于软件的打包部署。主线分支(master)则继续提交其最新的开发变更,即使软件在部署后又出现问题,也是基于dev分支进行修复,因此问题修复后不会引入主线分支(master)中未被验证的变更提交。在实际的工作中,我们不建议将变更直接提交至主线分支。主线分支只用于存储全量的提交记录,派生其他新的分支,并且接收其他分支的增量merge操作(在本章的最后部分,Git Work Flow会详细介绍不同分支之间的协同工作)。图3-9所示的是dev分支与master分支协同工作的示意图。

076-01

图3-9 dev分支与master分支的协同工作

了解了Git分支的使用场景之后,接下来再通过若干个git命令示例,讲解针对分支的操作,其中包含了分支的创建,不同分支之间的merge和diff等操作。

# 创建三个空的文件。
> touch {1.txt,2.txt,3.txt}
> git add .
> git commit -m "initial commit"
# 创建一个新的分支branch。
> git branch dev
> git branch --list
   dev
* master
# 切换至dev分支branch。
> git checkout dev
> git branch --list
* dev
   Master
# 在dev 分支branch中修改3.txt,并且新增一个文件,然后提交至本地仓库。
> echo "hello">3.txt
> git commit -am "modify the 3.txt"
> touch 4.txt && git add 4.txt
> git commit -m "add 4.txt file"

下面介绍几个常用的git命令。

  • git branch分支名:基于当前分支最新的提交创建一个新的分支。
  • git checkout分支名:将分支切换到指定的分支。
  • git checkout -b分支名:基于当前分支最新的提交,创建一个新的分支,然后切换(checkout)至新的分支。

至此,当前的Git仓库存在两个分支,分别为master和dev,dev分支是基于master创建而来的,而在dev分支中又有两个新的提交,通过git log命令可以看到它们之间的派生关系。

> git log --graph --abbrev-commit --decorate --oneline --date=relative --all
* 2128296 (HEAD -> dev) add 4.txt file
* 5f28260 modify the 3.txt
* 62df3ac (master) initial commit
# master最近一次的提交为62df3ac,dev分支最近一次的提交为2128296。

diff命令除了可以对比两次提交之间的不同之外,还可以应用于两个分支之间,命令格式为“git diff 分支1..分支2”,示例代码如下:

> git diff master..dev
diff --git a/3.txt b/3.txt
index e69de29..ce01362 100644
--- a/3.txt
+++ b/3.txt
@@ -0,0 +1 @@
+hello
diff --git a/4.txt b/4.txt
new file mode 100644
index 0000000..e69de29

假设此时dev分支的变更比较稳定,需要合并至master,那么此处可以使用merge命令进行操作,使master分支始终管理着“几乎”全量的变更记录。

# 先切换至master分支。
> git checkout master
Switched to branch 'master'
# 在master分支中执行merge命令。
> git merge dev
Updating 62df3ac..2128296
Fast-forward
 3.txt | 1 +
 4.txt | 0
 2 files changed, 1 insertion(+)
 create mode 100644 4.txt
# 再次执行log命令查看历史提交记录。
> git log --graph --abbrev-commit --decorate --oneline --date=relative --all
* 2128296 (HEAD -> master, dev) add 4.txt file
* 5f28260 modify the 3.txt
* 62df3ac initial commit

如果创建了错误的分支,或者想要删除某些历史分支(比如,feature/jiraxxx),则可以在本地仓库中删除该分支(3.4节将会介绍如何删除远程仓库中的分支),删除本地分支的命令为“git branch -d 分支名”。

3.3.5 stash命令

在通过示例讲解stash命令的用法之前,我们先来看一个场景:通常情况下,Git仓库会存在多个分支,而开发人员也会同时在不同的分支中进行切换。当阶段性的开发任务完成后,开发人员会将软件打包部署在UAT、SIT这样的内部环境中进行测试。与此同时,开发人员必须在DEV分支中进行下一阶段的开发任务。假如此时在UAT环境中发现了某些问题,要求开发人员立即进行修复,可是该开发人员目前正工作在DEV分支上,并且这些工作还不能进行提交,如果此刻立即切换到UAT分支,那么本地仓库的UAT分支仍然会看到DEV分支中未完成提交的变更,这些未完成的变更对UAT分支来说便是一种“污染”。下面通过具体示例做进一步的说明。

# 这是当前三个分支之间的关系。
> git log --graph --abbrev-commit --decorate --oneline --date=relative --all
* 2b7e46c (HEAD -> dev-0.0.1, uat-0.0.1) add new file 3.txt
| * 5de450b (master) add new file 2.txt
|/
* f86920c initial commit
# 在dev分支上存在尚不能提交的变更(因为工作还未完成),假设是对3.txt文件的修改。
> git status
On branch dev-0.0.1
Changes not staged for commit:
    (use "git add <file>..." to update what will be committed)
    (use "git checkout -- <file>..." to discard changes in working directory)
        modified:   3.txt
no changes added to commit (use "git add" and/or "git commit -a")
# 切换到UAT分支后,仍然会看到对文件3.txt的变更。
> git checkout uat-0.0.1
M       3.txt
> git status
On branch uat-0.0.1
Changes not staged for commit:
    (use "git add <file>..." to update what will be committed)
    (use "git checkout -- <file>..." to discard changes in working directory)
        modified:   3.txt
no changes added to commit (use "git add" and/or "git commit -a")

那么,这种情况该如何处理呢?难道要放弃在DEV分支上的变更,再切换至UAT分支吗?显然,这种做法很不合理,毕竟DEV分支上已经做了比较多的工作,就此轻易放弃非常可惜。针对这种情况,Git提供了stash命令,用于将当前分支未完成的提交暂时保存起来,这样切换至其他分支后,不会给其他分支带来“污染”,具体的操作步骤如下。

# 切回dev-0.0.1分支。
> git checkout dev-0.0.1
# 执行 stash命令暂存未提交的变更。
> git stash
Saved working directory and index state WIP on dev-0.0.1: 2b7e46c add new file 3.txt
HEAD is now at 2b7e46c add new file 3.txt
# 列出暂存列表。
> git stash list
stash@{0}: WIP on dev-0.0.1: 2b7e46c add new file 3.txt
# 切换至 UAT分支进行BUG修复。
> git checkout uat-0.0.1
> git status
# 看不到任何未提交的变更。
On branch uat-0.0.1
nothing to commit, working directory clean
# 当UAT分支的BUG修复完成之后,再切换至DEV分支。
> git checkout dev-0.0.1
# 从暂存区中将变更恢复至工作目录,继续完成未提交的工作。
> git stash apply
On branch dev-0.0.1
Changes not staged for commit:
    (use "git add <file>..." to update what will be committed)
    (use "git checkout -- <file>..." to discard changes in working directory)

        modified:   3.txt

这里需要特别说明的一点是,暂存列表中的记录在执行stash apply命令后不会自动清除,开发人员需要手动执行清除操作。

> git stash list
stash@{0}: WIP on dev-0.0.1: 2b7e46c add new file 3.txt
> git stash clear
> git stash list
#空。

3.3.6 reset命令

如果不小心将工作区的文件提交至暂存区,则可以使用reset命令进行回退(请回顾3.2.1节的示例)。如果不小心将错误的变更提交至本地仓库(已提交区),也可以通过reset命令进行回退,本节将通过具体示例讲解如何使用reset命令对已提交至本地仓库中的错误变更进行回退操作。

# 假设当前有三个提交,其中,b732dd4提交存在问题,需要进行回退操作。
> git log --oneline
b732dd4 third commit
2072ed5 second commit
b72db0e first commit
# 回退到上一个提交版本,且保留最后一次提交的变更。
> git reset --soft HEAD^
> git log --oneline
2072ed5 second commit
b72db0e first commit
# 将最后一次提交的文件回退到暂存区,等待下一次的提交或修改。
> git status
On branch master
Changes to be committed:
  (use "git reset HEAD <file>..." to unstage)

        new file:   c

通过reset命令指向HEAD指针的上一个提交,也就是“HEAD^”,此时就会出现“HEAD=HEAD^”,这一点有些类似于数据结构中链表移除元素的做法。当最后一次提交从本地仓库移除并重新进入暂存区后,就可以对回退的文件进行修改,然后再次执行提交操作。另外,使用“--soft”参数的最大好处是,如果在reset之前,工作区存在尚未提交的变更,那么该变更也会保留下来,如图3-10所示。

080-01

图3-10 soft reset会保留未提交的变更并且不会更新暂存区索引

“git reset --soft HEAD^”命令可用于将HEAD指针指向上一次提交,但其并不会从暂存区中移除最后一次提交的数据,如果使用“git reset --mixed HEAD^”命令,则除了将HEAD指针指向上一次提交之外,同时还会更新暂存区的索引,将最后一次提交的数据从暂存区中移除。

> git reset --mixed HEAD^
> git status
On branch master
Untracked files:
    (use "git add <file>..." to include in what will be committed)
        d
no changes added to commit (use "git add" and/or "git commit -a")

直接使用git reset命令时,默认使用的是“--mixed”参数,其工作原理如图3-11所示。

081-01

图3-11 mixed reset会保留未提交的变更,同时还会更新暂存区索引

除了“--soft”和“--mixed”之外,还有另外一种参数“--hard”,该参数不仅会移除本地仓库中的提交记录,还会一并移除工作目录中未完成提交的变更,是一种风险性较强的操作,在使用的过程中应该慎重,请看如下的示例演示。

# 本地仓库当前包含三个文件a、b和c,下面是历史提交记录。
> git log --oneline
330d908 third commit after soft reset
2072ed5 second commit
b72db0e first commit
# 修改文件a,增加一行内容,暂不做提交。
> echo "new line">a
# 执行hard reset。
> git reset --hard HEAD^
HEAD is now at 2072ed5 second commit
#检查本地目录下的文件,会发现该操作不仅丢弃了对a的变更,就连最后一次提交的文件c也被移除了(如果文件a是未追踪(Untracked)状态,那么文件a的变更将不会丢弃)。

hard reset的工作原理图如图3-12所示。

082-01

图3-12 hard reset会移除本地仓库中已提交的记录及工作区间中未提交的变更

这里需要特别说明的是,HEAD代表当前最新一次提交的指针,“HEAD^”是前一次,所以使用git reset命令也可以直接指向某次提交的id,比如git reset --soft 2072ed5。

3.3.7 标签的操作

当开发进入某个里程碑阶段时或需要发布时,会基于某次稳定的提交创建标签(Tag),Git支持两种类型的Tag:轻量级Tag和标记Tag。其中,轻量级Tag与分支极为类似,最大的区别是分支的HEAD指针会随着新的提交不断变化(向前),而Tag与某次提交绑定之后将不再变动。

> git log --oneline
f2e2470 third commit
0b55fcc second commit
da33e16 first commit
# 基于提交f2e2470 创建一个轻量级Tag。
> git tag 'v0.0.1' f2e2470
# 列出本地仓库中所有的Tag。
> git tag -l
v0.0.1
#查看该Tag的明细。
> git show v0.0.1
commit f2e24701e9ade311f1f44bb2862360a0066acaa8
Author: Alex Wang <alex@wangwenjun.com>
Date:   Sat Feb 20 02:15:11 2021 -0800

    third commit

diff --git a/3 b/3
new file mode 100644
index 0000000..e69de29

上述示例代码演示了如何创建Tag、列出本地仓库中所有的Tag,以及查看某个Tag的明细的操作方法。除了这些操作之外,还可以直接执行checkout Tag命令,此操作与checkout分支类似。

如果在创建Tag的时候想要增加一些描述信息,则可以使用标记Tag,即使用“-m”或“--message”参数添加描述信息。

#“--annotate”参数可用于声明该Tag为标记Tag,也可以简写为“-a”,“-m”或“--message”后
    面跟着的就是描述信息。
> git tag --annotate -m 'the version v0.0.2' 'v0.0.2' 3a43ac6
> git tag -l
v0.0.1
v0.0.2
> git show v0.0.2
tag v0.0.2
Tagger: Alex Wang <alex@wangwenjun.com>
Date:   Sat Feb 20 02:25:07 2021 -0800
#这里是标记Tag的message信息。
the version v0.0.2

commit 3a43ac664bfd8dcb595740c85387ab3c5cee2ad0
Author: Alex Wang <alex@wangwenjun.com>
Date:   Sat Feb 20 02:23:36 2021 -0800

    fourth commit

diff --git a/4 b/4
new file mode 100644
index 0000000..e69de29

Tag既可以创建,也可以删除,本地仓库中的删除方法与分支的删除方法类似,使用“-d”参数即可达到删除Tag的目的。

> git tag -d 'v0.0.1'
Deleted tag 'v0.0.1' (was f2e2470)
> git tag -l
v0.0.2

关于Tag的使用场景,在3.6节讲解Git Work Flow时还会有所涉及。

3.3.8 “.gitignore”文件的规则

通常情况下,开发人员会借助一些集成开发工具进行软件开发,比如,IntelliJ IDEA、Eclipse等。除了代码文件和目录结构之外,这些集成开发工具还会生成一些额外的配置文件或目录,诸如bin、classes、target、“.idea”“.project”等。这些额外的配置文件往往会与开发者的本地环境紧密相连,如果将这些文件提交至版本仓库进行管理,那么其他人检出这些文件之后可能会引起错误,毕竟不同的开发者本地磁盘的路径很可能也不相同。针对这种情况,Git提供了忽略某些文件的解决方案——编辑“.gitignore”文件。将所有需要被版本控制忽略的文件编辑在“.gitignore”文件之后,Git就不会再将其纳入版本管理中了,从而也不会再提交这些文件了。

# 假设Git版本管理的项目路径下存在如下一些文件和子目录:
> ls
bin  lib  logs  sample.iml  src  target
#除了src及其子目录之外,其他的都不能纳入Git的版本管理中。
> git status
# Git提醒你需要将它们纳入暂存区。
On branch master
Untracked files:
    (use "git add <file>..." to include in what will be committed)

        bin/
        lib/
        logs/
        sample.iml
        src/
        target/

nothing added to commit but untracked files present (use "git add" to track)
#对于这种情况,我们可以简单地写一个“.gitignore”文件,忽略除了src之外的其他文件和目录。
> cat .gitignore
target/
*.iml
bin/
logs/
lib/
> git status
On branch master
Changes not staged for commit:
    (use "git add <file>..." to update what will be committed)
    (use "git checkout -- <file>..." to discard changes in working directory)

        modified:   .gitignore

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

        src/

除了src目录之外,Git将忽略其他的文件和目录,“.gitignore”文件需要放置在项目工程的根路径下才会生效,除了上文示例代码中的规则配置之外,“.gitignore”还支持如下规则。

  • “test/”或“test/*”:忽略整个test目录及其子目录。
  • “test/*.zip”:忽略test目录下所有的zip文件。
  • “test.txt”:忽略所有名为test.txt的文件。
  • “!/test/test.txt”:忽略了整个test目录,但不会忽略test.txt文件。
  • “/*/test.txt”:忽略二级目录下所有的test.txt文件,但不会忽略三级目录下的test.txt文件。
  • “/**/test.txt”:忽略所有目录下的test.txt文件。