相信大家在平常工作中其实已经基本都开始在使用git了,我自己从开始工作到现在也使用了快2年的git,但是我们真的能算"掌握"git了吗,其实很多时候我们只是会了git最简单的一部分,但是对于背后的原理以及一些高级的使用就是一知半解,所以当我们碰到了一些情景需要用到这些高级操作时就会手足无措。这个系列主要就是对git的原理进行剖析并且帮助大家理解和学会如何使用这些高级操作,然后也会对一些工作中的场景例子进行讲解总结。这里假设大家已经了解了基本的git的应用,如果不太熟悉的话可以去google一下,对于git的基础教学其实网上很多材料的,这里就不再赘述。话不多说,让我们进入主题。
Git版本迭代
上面这张图比较了传统的版本控制系统与git的差别,图的上半部分代表的就是传统的版本控制系统,在每个新的版本中存储的是对前一个版本的的变化(delta),而git采取的是完全不同的办法,如图中的下半部分,git的每个新版本都是存着一个快照而非差异。已上图为例,从版本1到版本2,我们修改了A和C,那么此时git就会创建新的A1和C1的文件对象,而因为B没有变动,所以版本2中的B会指向版本1中的B
Git文件状态
在git中,文件有三个状态:已修改(modified),已暂存(staged),已提交(committed),如上图所示,工作区中存放着git控制的文件,而这些文件是从对象库(即版本库)中检出的,当我们在工作区对文件进行修改然后执行git add之后其实就是将修改的文件放入了暂存区,接着当我们执行git commit时,其实就是将暂存区中的文件存到图版本库之中。这里要注意的是在从暂存区提交至版本库时,我们需要放入一个commit的message来描述这次提交的目的,这里这个message是不能为空的,不然就会见到empty message will abort commit这个警告。
Git中的对象及背后原理
在git中存在4种对象用来帮助我们进行版本管理,它们分别是blob,tree,commit,tag,blob对象用来存放文件的内容,tree对象用来存放目录以及文件信息,commit对象存放每个commit的信息,而tag存放的就是跟标签有关的信息,这里要注意的是git只关心文件的内容,所以在git中不管文件存在什么位置叫什么名字,只要两个文件内容是一摸一样的,那么背后永远只有一个blob对象,这个特性使得git能使用我们之前说到的快照功能而又不用担心存放的内容过大,因为每次新的commit,只要文件内容不变,其实我们只需指向之前的blob对象,而不需要重复的创建一个新的对象。在我们的项目中,进入.git文件夹下面我们可以看到一个叫objects的文件夹,这个文件夹下就是存放着我们以上说的这些对象文件,文件名其实就是经过sha1算法获得的一串id,而内容就是我们对象的内容经过压缩算法后的结果。那么现在就让我们用一个例子来讲解下我们在做git操作时背后究竟发生了什么
-
在一个git初始化的空项目中创建一个文件
echo '123' > index.html
并且通过git add将文件加入暂存区中,这个时候git就会创建出一个blob对象 -
我们创建一个config文件夹
mkdir config
,这里就要注意,git不会去在意空的文件夹,所以这里甚至连工作区的untrack变动都没有,如果我们真的想创建一个文件夹但是有还没有打算在里面放置内容的话,惯例我们可以在文件夹下创建一个.keep或者.gitkeep文件 -
我们在config目录中创建一个database.yml文件
echo 'super-secret-password' > config/database.yml
,然后git add,这时候git就会创建一个新的blob对象 -
执行git commit,这时候git就会开始创建tree对象了,git会先创建一个代表config文件夹的对象并且会指向它包含的blob对象,也就是前面database.yml的blob对象,就着还会创建项目的根目录的tree对象,指向config的tree对象和index.html的blob对象,最后git还会创建一个commit对象,这个commit对象就指向了根目录的tree对象。在创建完commit对象之后,就会将现在的master分支指向这个commit对象,同时因为我们现在就处于master分支,所以HEAD指针也是也会指向着master分支
-
修改一下index.html文件,
echo 'hello world' >> index.html
,并且执行git add,这时候因为index.html发生了改变,所以就会创建一个新的blob对象,执行git commit,这时候因为产生了一个新的blob对象所以上层也会产生一个新的tree对象,由于config目录并未改变,所以新的tree对象就直接加一个指向之前config的tree对象,接着创建新的commit对象,并把master和HEAD指向这个新的对象 -
在根目录下创建一个key.txt,内容跟database.yml一样,
echo 'super-secret-password' > key.txt
,执行git add,此时由于内容相同,git就不会再重复创建一个新的对象了,执行git commit,此时由于根目录发生变化所以会创建一个新的tree对象,这个对象指向原先的config的tree对象和index.html的blob对象,接着应为多了一个key.txt并且内容与database.yml一致,所以tree也会指向之前database.yml的blob对象,最后创建新的commit对象并将master和HEAD指向这个对象
以上的例子中我们一共创建了10个对象,我们也可以通过执行git count-objects
来查询当前一共有几个对象
到这里其实我们就算是对git背后的对象及原理有一个了解了,其实如果我们去到上面提到的objects目录,然后去执行这些操作就可以看到这些对象文件是如何被创造出来的了,同时也可以使用我们之后会介绍的git cat-file
指令去查看类型和内容,有兴趣的话可以去实验一下,相信你就会完全掌握我们上面说的这一切了,最后我们看下面这张图中,objects目录下其实是先将对象id的前两位抽出来当做文件夹名,然后剩下的位数当做文件名来存放对象内容的,为什么要这么做呢?因为在一些操作系统下如果在一个文件夹中放了过多的档案,那么读取效率就会变得很差,所以git使用了这种方法来避免这个问题。
Commit
相信大家都知道可以使用git log来查看之前的一些commit信息,它的格式如图所示
这里可以看到git的commit id是一个摘要值,这个值就是之前提到的commit对象的sha1值,这个跟传统的版本控制使用数字递增来标示每个commit是不同的,因为git是分布式的版本控制系统,所以数字的方式是无法处理这样的情景,因为每个人的主机上都有控制着一份版本库,每个人都可以在自己的主机上做提交,如果用数字的话那就会出现好几个commit id是1,2,3....这样的情况,很明显就冲突了。另外可以看到在commit id下面有一行显示作者的姓名和邮箱的,这个姓名和邮箱又是怎么得来的呢,其实git有三个地方可以设置
- /etc/gitconfig (几乎不会使用) ,通过
git config --system
设置 - ~/.gitconfig (很常用) 通过
git config --global
设置 - 针对于特定项目,在项目的.git/config文件中 (很常用) ,通过
git config --local
设置
在配置完之后就会再相应的文件中看到类似的信息被加入
如果三个都设置了的话优先级会是 3 > 2 > 1, 当然3的话只会在特定项目中生效,2只会在当前主机用户的文件系统中生效,1则是全局生效,通过git config user.name
就可以查看到当前上下文中的信息
指令
-
重命名文件
可以使用
git mv test1.txt test2.txt
,这里要注意的是,git mv背后其实是先做了mv test1.txt test2.txt
,接着再去执行git rm test1.txt
,再git add test2.txt
,所以假设我们现在执行git reset HEAD test1.txt
的话,这里会撤销删除test1.txt,但是test2.txt依然会在暂存区中,因为背后其实是执行了两条命令,所以对源文件的git操作并不影响目标文件 -
消除当前上下文中已配置的用户名和邮箱
git config --local --unset user.name
-
修改上次提交的消息
git commit --amend -m 'new message for last commit'
,这个指令同时也会将暂存区中新有的改动一起合并到commit中,但这里要注意的是如 果有远程的版本服务器,尽量不要在已经推送到远程服务器之后还去对commit进行修改,因为其他人可能 已经在使用当前远程上的内容了。另外如果在更新了当前的git的用户名和邮箱后想要修改上一次提交的用户名和邮箱可以执行git commit --amend --reset-author
-
查看git log
git log -3
查看最近3条commitgit log --pretty=online
以简单的一行形式看commit历史git log --pretty=format:"%h - %an, %ar : %s"
可以自定义format来输出commit历史git log --graph
图形化查看提交历史git log --graph --abbrev-commit
提交信息简写git log --author='desmond'
查找一位作者叫desmond的相关commitgit log --grep='wtf'
查找commit信息包含wtf的相关commitgit log -S "elixir"
查找字符串elixir在哪个commit中加到哪个文件中git log --since="9am" until="12am"
查询早上9点至12点之间的commitgit log -p test.html
查看某个文件的提交记录,-p会将具体修改的情况一并显示 -
.gitignore的一些配置方法
*.a
忽略所有.a结尾的文件!lib.a
lib.a除外/TODO
仅仅会略根目录下的TODO文件,不会包括子目录中的TODObuild/
忽略build目录下的所有文件doc/*.txt
忽略doc下的txt结尾文件,但是doc子目录中的txt结尾文件不会包括/*/*.txt
忽略所有一级目录下的所有txt结尾文件,但是一级之下的子目录不会包括/**/*.txt
忽略一级目录及其子目录下的txt结尾文件 需要注意的是.gitignore只会对在设定它之后新增的文件生效,如果在之前就已经被纳入到版本库的文件,即使命中也不会生效
-
查看我们输入的内容在git的sha1算法计算过后的sha1值
echo '123' | git hash-object --stdin
-
查看当前的git配置,包括用户名,邮箱等
git config --list
-
修改git提交时的默认编辑器(默认为vim)
git config --global core.editor emacs
-
删除掉.gitignore里面过滤的文件
git clean -fX
-
只提交文件中的部分内容
git add -p index.html
,接着就会进入编辑模式可以选择行的把想要提交的内容保留,此时文件在git的状态中就会便拆成两部分,一部分是保留的修改放在暂存区中,第二部分是保留在工作区的修改 -
查看SHA1值背后代表的对象
git cat-file SHA1值 -t
可以查看该SHA1值背后代表的是一个什么对象(即blob,tree,commit等),同时git cat-file SHA1值 -p
可以查看对象背后保存的内容 -
git add 与 git commit 操作合二为一
git commit -am 'hello'
场景
-
从暂存区恢复到修改状态 & 取消修改状态
有时候如果我们在git add之后发现想要撤销回这些修改,我们可以使用
git reset HEAD 文件名
,将暂存区中的修改文件恢复至修改状态,如果连修改状态也不需要呢?那我们就可以使用git checkout -- 文件名
来取消已修改文件。如果是使用git add将一个新增的文件放入暂存区中的时候,我们还可以使用git rm --cached 文件名
来撤销操作,在文件是新增的时候,这条命令与git reset HEAD
是一样的效果 -
设置别名
在git中,如果觉得指令太长的话,我们可以通过设置别名来简化,例如
git config --gloabal alias.co checkout
,这样在我们使用git checkout
时就可以直接使用git co
了,通常我们还会用br代表branch,st代表status, 或者譬如我们使用git log --oneline --graph
时觉得太长,就可以用一个l来作为别名,git config --gloabal alias.l "log --oneline --graph"
,再比如我们想要设置git默认gui gitk的别名可以使用git config --global alias.ui '!gitk'
这里加感叹号是使gitk以外部命令的形式跑,也就是前面不会加一个git,我们通过这个技巧就可以给一些不以git开头的命令作别名
Q&A
-
git rm 与 rm 有什么区别呢?
git rm背后做了两件事,首先就是执行rm命令删除了文件,接着它会将被删除的文件加入到暂存区中。
-
如果git使用快照的方式,那感觉很浪费空间啊,我可能只改了一个字都会创造一个新的blob对象
从创建快照的角度来说确实有一点,但是git本身自带了资源回收机制,在发动这个机制时,git会使用高效的方式来压缩对象并且做索引,我们可以通过手动的去调用
git gc
来触发资源回收这时候我们看到pack文件夹下就会多出来一个pack文件和index文件,我们可以再通过调用
git verify-pack -v .git/objects/pack/pack-ea00f1558d67a7df25bf9744f3d83a17a7a2bf43.idx
来查看打包的状况这里看到第二个红框中的对象其实是第一个红框中的对象修改之后的blob对象,但是他的大小只有9,为什么呢,原因是因为它参照了前面的blob对象,也就是说在打包之后,其实使用了delta备份的方式来有效降低大小的,只有在打包前才是快照那样完整的文件内容的。
当然除了对象之外,包括像HEAD,branch之类的信息也会被回收。
那资源回收机制默认是在什么时候会自动触发呢
- 当在
./git/objects
目录的文件或是打包过的pack文件过多的时候 - 当执行
git push
的时候
- 当在
-
git add . 和 git add * 区别
git add .
会把本地所有untrack的文件都加入暂存区,并且会根据.gitignore做过滤,但是git add *
会忽略.gitignore把任何文件都加入
Tips
- Mac的terminal中可以执行
ctrl + l
来清屏,ctrl + a
可以将光标跳至行头,ctrl + e
可以跳至行末,cd -
可以回到上一次访问的目录 - Homebrew是Mac中很强大的包管理工具,我们可以使用它来安装很多程序,例如git等
- 推荐一个Homebrew中可以安装的包叫tree,可以用来看当前目录结构,在我们学习git的时候还是蛮有用的,并且它可以通过-L参数指定最多要看的层数, etc. tree -L 1(只看一层目录结构)