本文不算专业教程,只是对Git的基础知识和常用功能做梳理,以满足日常开发的需求。
Git是什么:分布式版本控制系统。
版本控制系统是什么:版本控制系统是一个能够记录文件变更以及支持多人协同工作的软件。
分布式是什么:
相比较于集中式而言,集中式如SVN,服务器作为中央处理器,只有服务器上面的版本库才是真正完整的,每个人的电脑上只记录着最新的文件内容。这有两个弊端,首先,一旦服务器的版本库丢失,就只能找到最后的文件内容,找不到整个文件变化历史。其次,一旦服务器停止工作,任何一台电脑都无法提交对文件的改动,协同工作就会停止。
分布式系统,个人电脑在复制服务器的版本库时,并不仅仅复制文件最新内容,而是将整个版本库复制下来就,每个人的电脑上都保存着一个完整的版本库,所以每个人的电脑上都能找到文件的演变历史。即使服务器没有运行,每个人的电脑也可以提交对文件的改动(这里指提交到本地),所以每个人的电脑也都可以进行版本管理,而集中式在本地没有版本库这个东西,所以一旦离开了服务器,无法进行版本管理。分布式系统只在需要更新来自服务器的文件改动时,才需要依赖服务器。
这里只记录最基本的配置:配置用户名和邮箱,作为你进行提交操作时的个人信息
git config global user.name "name"
git config global user.email [email protected]
"global"表示这次是全局的配置,全局配置只需要配置一次,这些信息会被记录下来供后续使用。
使用"git config --list"命令可以列出所有git现在能找到的配置。
有两类方式:
本地创建:
在你想要进行版本控制的文件夹里,使用以下命令:
git init
即可创建Git仓库,创建完成后在文件夹里会多出一个“.git”文件夹,git的所有数据信息都在该文件夹里。
拉取服务器的仓库到本地:
将服务器的仓库拉取到本地作为本地仓库,使用以下命令:
git clone url
使用具体网址代替“url”即可,此时会将服务器的仓库复制到本地。
尽管已经创建了仓库,但是默认的Git不会对文件夹里的文件进行管理(即跟踪),必须告诉Git哪些文件需要被跟踪。使用“git add”命令告诉Git哪些文件需要被跟踪,该命令后面可跟随文件名、文件夹名等等作为参数。如:
// 跟踪“test.txt”文件
git add test.txt
// 跟踪“example”路径下的所有文件
git add example/*
// 跟踪仓库里的所有文件
git add .
当你修改了被跟踪的文件,需要提交变更,和别的版本控制系统(比如SVN)不同的是,SVN只要一条命令就能完成一次提交,Git需要两条,分别是“git add”和“git commit”命令,下文会对此进行解释。另外可以看到,“git add”是一条多功能命令,可以对未被跟踪的文件进行跟踪,也可以将已跟踪的被修改的文件进行提交,还有其它的功能会在下文介绍。以下命令将一个被修改的文件进行提交(“git commit”命令的“-m”参数表示对本次提交添加说明):
git add test.txt
git commit -m "提交test文件"
注意:只有使用了“git comit”命令才算真正提交了变更,只有使用了“git commit”命令提交的变更,你才能在Git中找到这次的提交历史(记录)。
Git通过分区来对文件进行管理,当使用“git add”命令和“git commit”命令时,文件就在不同的分区进行流动。主要可以分为三个区:工作区、暂存区、仓库区(也叫版本库),如下图(以下图片来自网络):
说完了分区来说文件状态,分区和文件状态结合起来互相理解。Git里面文件的状态以及变化如下图所示(以下图片来自网络):
工作目录里的文件只有两类状态:已跟踪或未跟踪。对于已跟踪的文件,又可以细分为未修改、已修改、已暂存。
Git命令、分区、文件状态结合来看的关系是:
理解好文件状态很重要,因为查看文件状态和撤销修改的操作,都是根据文件的不同状态来考虑的。
当你修改、暂存、提交了若干文件,这些文件可能分别处于不同的状态,此时需要一个命令查看当前各个文件的状态:“git status”。下面列举一些输入该命令后输出的信息说明该命令的效果:
$ git status
On branch master
nothing to commit, working directory clean
再来看一个例子:
$ git status
On branch master
Changes to be committed:
(use "git reset HEAD ..." to unstage)
modified: t1.txt
new file: t2.txt
Changes not staged for commit:
(use "git add ..." to update what will be committed)
(use "git checkout -- ..." to discard changes in working directory)
modified: t3.txt
Untracked files:
(use "git add ..." to include in what will be committed)
t4.txt
可以看到,Git的显示方式就是分别列出处于不同状态的文件,而且,对处于已暂存状态的文件还标识了它之前的状态。
接着来看一个比较诡异的:
$ git status
On branch master
Changes to be committed:
(use "git reset HEAD ..." to unstage)
modified: t1.txt
Changes not staged for commit:
(use "git add ..." to update what will be committed)
(use "git checkout -- ..." to discard changes in working directory)
modified: t1.txt
同一个文件同时出现处于已暂存和已修改状态,这是怎么做到的呢:
对于一个处于未修改状态的文件,对他进行改动a,然后提交到暂存区,然后,不要执行“git commit”命令,继续对该文件进行改动b,此时使用“git status”命令就会得出上面的输出。简言之,就是对处于已暂存状态的文件再次进行修改,就会出现以上输出。此时暂存区保存的是改动a,如果此时使用“git commit”命令,只有改动a会被提交到版本库,改动b不会被保存起来。
前文一直都在讨论如何提交文件,如何保存文件,但是如果有些文件自始至终都不想被Git跟踪呢(典型的是在编译过程里系统自动生成的中间文件),我们既不想在每次使用 “git status”命令时看到这些文件被列出,也不想在使用“git add .”进行快捷提交时不小心把这些文件全部提交到暂存区。这时候就需要告诉Git那些文件是确实不需要被跟踪的,这就是忽略文件。
在工作目录下创建一个名为“.gitignore”文件,该文件里面可以定义哪些文件不需要被Git跟踪,该文件的语法如下:
glob 模式是指 shell 所使用的简化了的正则表达式。 星号( * )匹配零个或多个任意字符;[abc] 匹配任何一个列在方括号中的字符(这个例子要么匹配一个 a,要么匹配一个 b,要么匹配一个 c);问号(?)只匹配一个任意字符;如果在方括号中使用短划线分隔两个字符,表示所有在这两个字符范围内的都可以匹配(比如 [0-9] 表示匹配所有 0 到 9 的数字)。 使用两个星号(*) 表示匹配任意中间目录,比如 a/**/z 可以匹配 a/z , a/b/z 或 a/b/c/z 等。
.gitignore部分写法举例如下:
# 忽略所有扩展名为“.a”的文件
*.a
# 但是不要忽略(也就是要跟踪)lib.a文件
!lib.a
# 忽略当前文件夹下的“TODO”文件,但不包括子文件夹下的“TODO”文件
/TODO
# 忽略“build”文件夹下的所有文件
build/
# 忽略“doc”文件夹下的所有扩展名为“.txt”的文件,但不包括“doc”文件夹里的子文件夹里面扩展名为“.txt”的文件
doc/*.txt
# 忽略“doc”文件夹(包括该文件夹的子文件夹)下的扩展名为“.pdf”的文件
doc/**/*.pdf
前文一直在讨论如何“往前走”:修改文件、提交暂存、提交版本库,现在来看如何往后退。当你发现已经提交(提交到暂存区或者提交到版本库)的文件有问题时,就会需要撤销操作,但在此之前,还要学会如何查看提交历史。
使用“git log”命令即可查看提交历史,输出举例:
$ git log
commit e9c673d8f4b65168a246a1b58327e5a6c044bd7e (HEAD -> master)
Author: authorName
Date: Tue Jul 2 10:47:20 2019 +0800
commit t2
commit ea1eb72062a7584167489bc454fbab05171299ee
Author: authorName
Date: Tue Jul 2 10:46:49 2019 +0800
commit t1
上面列出了两次提交,我们来看这几个标识:
对于已经被跟踪的文件,共有三类状态:已修改、已暂存、未修改,撤销操作就是将这些状态退回到它们之前的状态。
已修改–>未修改:
如果你的工作区有已修改的文件,使用“git status”命令,Git会提示你如何撤销修改。
$ git status
On branch master
Changes not staged for commit:
(use "git add ..." to update what will be committed)
(use "git checkout -- ..." to discard changes in working directory)
modified: t1.txt
no changes added to commit (use "git add" and/or "git commit -a")
前文说过,在“Changes not staged for commit”语句下面的是处于已修改状态的文件,该条语句下面的第二行就是Git提示你该如何撤销修改,此时执行“git checkout – t1.txt”即可撤销修改,将文件回退到未修改状态。
已暂存–>已修改:
类似的,如果你的暂存区有文件,使用“git status”命令,Git会提示你如何撤销暂存。
$ git status
On branch master
Changes to be committed:
(use "git reset HEAD ..." to unstage)
modified: t1.txt
在“Changes to be commited”语句下面的是处于已暂存状态的文件,该语句下面的第一行就是Git提示你该如何撤销暂存,此时执行“git reset HEAD t1.txt”即可撤销暂存,将文件回退到已修改状态。这个操作不会丢失你对文件做的修改,只是状态变了而已。
已暂存+已修改,各自回退:
现在考虑对一个处于已暂存状态的文件再次进行修改,此时的撤销就有两类,一是对新的修改进行撤销,二是对已暂存的操作进行撤销,通过“git status”来看Git如何提示。
$ git status
On branch master
Changes to be committed:
(use "git reset HEAD ..." to unstage)
modified: t1.txt
Changes not staged for commit:
(use "git add ..." to update what will be committed)
(use "git checkout -- ..." to discard changes in working directory)
modified: t1.txt
根据提示,使用的撤销命令是一样的。有个需要注意的地方是,对于暂存后没有进行过修改的文件,当你撤销暂存,你的修改不会丢失。但是对于暂存后又修改过的文件,当你撤销暂存,第一次的修改就会丢失,只有第二次的修改还保留在原来的文件里。
未修改,版本跳转:
处于未修改状态的文件,同时也是属于已提交的文件(所谓的未修改就是自上次提交之后没有修改),此时所谓的撤销其实就是将文件回退到前面某个提交的版本,即版本跳转。使用“git reset 某次提交的SHA-1值”命令可以进行版本跳转。
小结:
本文开头在介绍版本控制系统的时候,提到了它可以支持多人协同工作,即支持并行开发,而“分支”功能就是对多人并行开发这个需求的强有力支持。使用分支意味着你可以把你的工作从开发主线(主线其实就是主分支,这里假设它是Git默认创建的master分支)上分离开来,以免影响开发主线。
想象一下有两个人同时开发一个项目的不同功能,为了尽可能地减少彼此间的相互影响,你们应当创建各自的功能分支,等到功能开发完了,再将代码提交到主分支上,合并在一起,整个流程大概是下图所示,图里的每一个矩形表示一次提交(commit):
以上就是分支的作用和实际的开发流程举例。其实,即使只有你一个人开发一个项目,也有可能有需要使用到分支,因为新功能的开发不一定总是线性串行的。想象一下公司让你开发一个功能a,开发到一段,还没开发完成,公司又让你临时新增一个功能b,在功能b还没开发完成,公司又让你开发一个功能c,谁都不知道最终哪个功能会先完成,功能的优先级不是固定的。解决这个问题的方法就是每开发一个功能都单独开一个分支,这样各个功能的开发之间互不影响,开发完了才合并到主分支上。
在使用分支之前,还要简单介绍一下分支的工作原理,这样用起来才不会觉得困惑。Git的分支,实际上一个是指向某一次提交的指针,当你创建一个分支的时候,其实只是创建了一个指向最新一次提交的指针。当你在某个分支上不断创建提交,分支指针会不断移动,最终只是指向最新一次提交。以上图的工作流程为例,当你创建a分支的时候,其实是这样子的:
主分支(master)是指向主线的最后一次提交的指针,当在主分支上创建分支a时,其实也只是创建一个指向主分支最新一次提交的指针。HEAD是一个特殊的指针,HEAD是指向某一个分支指针的指针(比如它现在指向master分支指针)。实际上,HEAD指针指向的地方,就是工作区会显示的内容。当你在不同的分支上进行切换,其实只是修改HEAD指针,将HEAD指针指向不同的分支指针。比如现在你要在a分支上开发新的功能,你首先要切到a分支:
当你在a分支上开发功能并提交了几次之后,分支指针a会跟随你的提交不断移动,同时HEAD指针跟随分支指针a移动,像这样子:
现在,你切回到master分支并且创建b分支并切换到b分支上,准备开发功能b:
当你在b分支上进行几次提交之后,分支指针b跟随你的提交不断移动,同时HEAD指针跟随分支指针b不断移动:
现在,功能a、b都开发完了,你需要把它们合并到master分支里,我们来看合并分支时各个分支指针是如何变化的。首先你切换到master分支上,然后将a分支合并进来:
你会发现没有产生任何新的提交记录,由于master分支是a分支的祖先(更准确的说,master分支指向的提交是a分支指向的提交的祖先),这类合并仅仅是将master分支指针前移到a分支指针指向的那次提交,仅此而已。这类合并方式称为“Fast-forward”。
现在,你会删除a分支,然后将b分支合并进来,由于此时master分支不再是b分支的祖先,所以不是“Fast-forward”合并,而是创建一次新的提交:
在你使用分支的过程里,分支的变化(创建、切换、提交、合并、删除)的简单原理大概如上所述,不打算深入描述原理,能够正常开发即可。
基本原理介绍完了,我们开始看具体操作。
使用“git branch”查看分支:
$ git branch
develop
* master
它会列出所有的分支并且用“*”标出当前所在分支,现在拥有一个“master”分支和一个“develop”分支,当前处在master分支上。
使用“git branch 分支名”创建新的分支,使用“git checkout 分支名”切换分支:
// 假设先执行“git branch feature”创建feature分支,然后执行“git checkout feature”切换到feature分支上,
// 在执行“git checkout feature”时,你会看到如下输出:
$ git checkout feature
Switched to branch 'feature'
输出的“Switched to branch ‘feature’”提示你已经切换到feature分支上了。
你也可以使用“git checkout -b 分支名”来实现创建并切换分支效果:
$ git checkout -b feature
Switched to a new branch 'feature'
提示的“Switched to a new branch ‘feature’”表示你创建了一个新的分支并已经切换到它上面了。
“git merge 分支名”:
使用“git merge 分支名”来合并分支,如果你想要将feature分支合并到master分支上,首先切换到master分支上,然后执行“git merge feature”即可。
“git merge 分支名 -m ‘描述信息’”:
如果你的分支状态符合“Fast-forward”合并模式,使用“git merge 分支名”就能完成一次合并,如果不符合,那么Git会要求你为本次合并输入说明。如果你本来就知道本次合并不符合“Fast-forward”模式,可以在合并的时候同时输入说明。
“git merge --no-ff 分支名 -m ‘描述信息’”:
对于“Fast-forward”模式合并的分支,你在上图应该能看出来,当你删除了被合并的分支以后,就真的删除了这条分支曾经存在过的痕迹,以后查看历史,没法体现该功能是在分支里面进行开发的。如果你想保留这类痕迹,可以在满足“Fast-forward”模式的情况下,使用“–no-ff”参数声明不使用该模式,这样就能保留痕迹。
使用“git branch -d 分支名”删除分支。
对“分支”小结如下:
在了解了分支的使用场景以及如何使用分支之后,考虑这样一个情况,你正在a分支上开发功能a,突然来了一个紧急功能b,此时,你需要切换到新的分支上开发功能b,但是在切换分支之前,Git会要求你提交当前修改,然而你又不想为一个开发了一半的功能生成一次提交记录,此时,“git add”命令已经不能满足需要,你需要另一类暂存:“git stash”命令。
“git stash”会将你的修改与暂存(即处于已修改状态的文件和已暂存状态的文件)全部保存起来,然后,它会清空工作区和暂存区,即将所有东西还原到最后一次提交之后的状态。此时,由于工作区和暂存区都是干净的,你可以随意切到别的分支去了,当你在别的分支完成工作,切回原来的分支,只需要将保存的内容还原回来即可。
“git stash”的这个功能还有另外一个用途,想象一下你对某个功能有几个实现方案,你可能想要将所有的实现方案都编写一遍,对比性能之后再进行提交。此时,你可以为每一个实现方案均创建一个分支,对比之后删除不要的方案所在的分支,但是这样比较麻烦,使用“git stash”你可以在当前分支完成所有操作,而且不会产生多余的提交记录。每当你编写完一个方案,保存该方案,然后工作区和暂存区被还原,编写下一个方案。当你完成并保存所有的方案之后,你可以将任意一次保存的方案还原回来,进行实验。最后,你将需要的方案还原回来,然后删除所有保存记录即可。
接下来描述一下具体使用过程以及语法。
首先,假设我们对某个功能编写了实现方案a,此时有一个处于已修改状态的文件和一个处于已暂存状态的文件:
$ git status
On branch master
Changes to be committed:
(use "git reset HEAD ..." to unstage)
modified: t1.txt
Changes not staged for commit:
(use "git add ..." to update what will be committed)
(use "git checkout -- ..." to discard changes in working directory)
modified: t2.txt
现在我们使用“git stash”暂存这些改动:
$ git stash
Saved working directory and index state WIP on master: 444fa91 create t2
然后看看文件状态:
$ git status
On branch master
nothing to commit, working tree clean
工作区和暂存区都被还原了,接着我们编写方案b,查看状态:
$ git status
On branch master
Changes not staged for commit:
(use "git add ..." to update what will be committed)
(use "git checkout -- ..." to discard changes in working directory)
modified: t2.txt
接着再次用“git stash”暂存改动:
$ git stash
Saved working directory and index state WIP on master: 444fa91 create t2
接着来看文件状态:
$ git status
On branch master
nothing to commit, working tree clean
现在我们来检验“git stash”命令的效果,使用“git stash list”查看暂存记录:
$ git stash list
stash@{0}: WIP on master: 444fa91 create t2
stash@{1}: WIP on master: 444fa91 create t2
可以看到暂存过的两次记录,他们按照暂存的时间顺序排列,最上面的是最近一次暂存的。我们使用“git stash apply 编号”将第一次还原回来:
$ git stash apply stash@{1}
On branch master
Changes not staged for commit:
(use "git add ..." to update what will be committed)
(use "git checkout -- ..." to discard changes in working directory)
modified: t1.txt
modified: t2.txt
no changes added to commit (use "git add" and/or "git commit -a")
我们第一次暂存了两个文件的改动,也成功地还原回来两个文件的改动,不足之处在于没有将原来处于已暂存状态的文件恢复到已暂存状态,如果想要完全还原成一模一样的话,使用“git stash apply --index 编号”命令:
$ git stash apply --index stash@{1}
On branch master
Changes to be committed:
(use "git reset HEAD ..." to unstage)
modified: t1.txt
Changes not staged for commit:
(use "git add ..." to update what will be committed)
(use "git checkout -- ..." to discard changes in working directory)
modified: t2.txt
现在,假设我们确定使用方案a,改动已经被还原回来了,接着删除使用“git stash”暂存的改动即可,使用“git stash drop 编号”进行删除:
$ git stash drop stash@{0}
Dropped stash@{0} (04be15ab2b106f6784bcee02000763ad55a2c8c7)
$ git stash drop stash@{0}
Dropped stash@{0} (c793ef413088ea51822b27c19a85e047c0fe8123)
当你删除stash@{0}的时候,后面的会往前“补”,所以第二次还是删除stash@{0}。你可以使用“git stash pop 编号”来还原同时删除暂存,这相当于“git stash apply”和“git stash drop”两条命令。
现在,你已经还原方案a并且可以进行工作了。
引用stash和你所在的分支之间并没有固定关系,你在a分支使用stash暂存的东西,可以在b分支上从stash取出来使用。
本文介绍了Git的简单使用和一些基本原理,包括对文件变化的跟踪和恢复、分支使用、stash暂存,旨在满足日常工作对Git的基本使用需求。