
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分支协同工作的示意图。

图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所示。

图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所示。

图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所示。

图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文件。