通过案例学Git
Git尽管有着非常详尽的官方教程,但突出一个量大管饱,不太适合现代人阅读习惯,导致新人不爱用。 例如我自己最早写实验的时候,是每次大改就复制出一个新文件夹来保证不丢东西。即便上完Software Engineering,记住的也只有Git Clone/Pull/Push。
本文的目的,是让读者掌握10%的用法,来应对90%的场景,包括:版本控制、多端同步、协作开发。
版本控制:昨天还好好的,今天怎么炸了?
假设用一台机器,独自开发,需不需要用Git呢?设想如下场景,昨天明明代码还好的,结果今天东改一点西改一点,忽然直接炸了,到底是哪儿改错了呢?
Q1. 我这段时间到底改了什么?
Q2. 我能不能回到昨天(代码还好的时候)从头再来?
不依赖Git的话,工作留痕是一个解决方案。比如
报表.doc
报表v10.doc
报表v12_last.doc
报表v12_last_final.doc
报表v12_last_final_提交版(不再修改).doc
报表v12_last_final_提交版(不再修改)_修改版.doc
显然,命名杂乱是一个问题,此外文件多的时候也很难看出每个版本改了什么。Git的一个最大的作用就是解决这个。
老生常谈
现在假设我有个git-tutorial文件夹,里面只有一个文件main.cpp。
git init
首先把这个文件夹用Git追踪,想象成你雇了一个G保安,让他盯好每天有谁进出公司。
$ git-tutorial> git status |
可以看到,保安.git文件夹已经就位了。我们让他开始干活,首先为公司员工做个登记。
git add
$ git-tutorial> git add main.cpp |
通过git add指令,我们的保安已经完全记住了main.cpp这个员工。其实从这一刻起,文件已经有两个版本了。不过这个我们后续再说。
git commit
确认无误,我们让保安将这个员工录入系统中。
$ git-tutorial> git commit -m "initial commit" |
至此,我们的员工main.cpp有了三个版本:真实的他,保安眼中的他,员工系统中的他。只是目前这三个版本是一样的。我们来稍微做些改变来理解这个区别。
用Git的思维看文件版本
工作区、暂存区、版本库
我们先对main.cpp做些改动
可以看到,G保安已经贴心地用蓝色线条为我们标出了目前的改动是8、9两行。换言之,真实的main.cpp和他记忆中的main.cpp不太一样了。
| 工作区 | 暂存区 / 版本库 |
|---|---|
| int y = 20; int z = 30; cout << x + y - z << endl; |
int y = 20; cout << x + y << endl; |
一般,我们把文件系统中的真实文件叫工作区(Working Dir)、保安记得但还没录入的改动叫暂存区(Staging Area)、已录入系统的改动叫版本库(Repo)。
我们告诉保安,没错,我就是想这么改,让保安记住这一时刻的文件。这通过git add来实现。
1 | $ git-tutorial> git add main.cpp |
Git保安不仅记住了这次改动,同时他用很简单的方式描述了到底改了啥。
照理说应该让保安把这个改动录入员工系统了,但这时候我们发现好像写错了,想计算的其实是x+y+z,所以我们又改了下文件。
这时候,显然三个版本就各自不同了
| 工作区 | 暂存区 | 版本库 |
|---|---|---|
| x+y+z | x+y-z | x+y |
- 这时候,我们如果执行
git commit,那保安将他记住的文件录入系统,版本库里的会是x+y-z。 - 如果我们执行
git add,那保安会记住当前的文件,暂存区里变成x+y+z。
小结与建议
通常而言,Git的开发正常流程是
- 修改文件系统中的文件(工作区)
- 通过
git add将工作区的文件记入暂存区 - 通过
git commit将暂存区的文件录入版本库
在”工作区>暂存区>版本库”这个流动中,git add负责前一步,git commit负责后一步。
为什么要区分暂存区和版本库呢?我每个改动都
git add秒接git commit不就好了吗?
确实,有人的习惯是事无大小全都git commit -m "1",有人的习惯是攒波大的,统一git add再git commit,但这都不是好习惯。
add的目的是防止文件丢失,比如你不小心删掉了正在编辑的文件,那很容易回到你add的那个时间点,所以add越频繁,你的损失就越小。add宜多。
commit的目的是回顾和与人交流,时间一久,哪个版本改了什么很容易忘,最容易查找的切入点就是commit记录。如果放眼望去都是毫无意义的message,那查起来就很累了。因此,最好是同一个commit完整地解决一个问题/引入一个功能。以后若是回到这个commit,代码应该能直接像现在一样跑起来。commit宜精。
那么,让我们完成这次更改吧,再执行一次git add最后git commit。
1 | $ git-tutorial> git add main.cpp |
好,我们的当前文件和git版本管理中的再一次一致了!这个状态叫”working tree clean”,没有版本不一致问题了。
查看改动(谁改了啥)
我们有几个方法可以查看改动。
git log
这里面可以看到完整的commit号以及commit信息,经常用来查阅“我目前这个版本是最新的吗”。
git blame
给每一行都注上最后commit的是谁,点击即可查看谁对这行做过什么修改。
git diff
- 比较工作区与暂存区的差异:
git diff - 比较暂存区与最新commit的差异:
git diff --cached
现在通常直接用IDE直接勾选对应版本/本地,比较直观。可以选择忽略空格空行。
异常处理(时光倒流)
正常开发是按git add再git commit的流程去走,文件按工作区>暂存区>版本库方向流动。但是难免有出错的时候,下面针对具体情况解释如何恢复文件,实现时光倒流。
文件整个变回历史版本:git checkout
如果想要把整个文件回退到暂存区(上次add)或版本库(commit)里的版本,注意是个危险操作,未保存的改动就永久消失了。
变回暂存区版本
- 用暂存区代替某个文件:
git checkout -- main.cpp - 用暂存区代替全部文件:
git checkout .
没有add过的话,暂存区等同于最新commit。注意经过checkout后,文件就没有”Changes not staged for commit”了,会变回上次add完以后的版本,从add到当前的变化消失。
变回历史某个commit的版本
- 用commit版本代替某个文件:
git checkout <commit> main.cpp - 用commit版本代替全部文件:
git checkout <commit> .
例如我想把main.cpp文件退回到最初commit,编号64bee6的版本(通常6位就够了),那执行git checkout 64bee6 main.cpp。可以认为上面是忽略了commit号来指代暂存区。如果要回到最新的commit,commit号可以用HEAD代替。
由于历史遗留问题,checkout同时管切分支和退回。比较新的退回可以用restore,不过还是减少记忆成本。
回退工作区部分修改(还没add)
新的IDE很多都支持局部回退功能,只要点击蓝色的diff窗口,就能进行一行或一段的回退。
回退第一次add,不再追踪文件:git rm
比如新建了一个test文件,并已经执行了git add。
$ git-tutorial> git status |
- 不再追踪test,但保留对应文件:
git rm --cached test - 直接将文件删除掉:
git rm -f test
一般来说直接删掉文件的情况比较少。有时候其实文件已经没了,但git留了个空文件,就用这个来去掉。
加错了文件,还没commit:git reset
比如git add错了一个文件(不应该属于这次commit),但是又不想影响本地文件。
- 回退一个文件的add:
git reset HEAD main.cpp - 回退全部文件的add:
git reset HEAD
工作区的当前文件不会受影响,相当于没对这些文件做add。
已经commit了发现错误,想挽回
commit在英文里有承诺的意思,所以一旦commit,一般而言不要轻易修改。不过视情况轻重,也有不同的应对方式。
刚刚commit完,发现错了:git commit --amend
比如漏加了个文件,第二次add完后用git commit --amend,会跟前一个commit合并为一个,保证commit的简洁。
回到上一个版本:git reset
前面说过,撤销add也是用git reset,这里完整写法相当于git reset --mixed HEAD,区别如下:
git reset --soft:工作区不变,撤销commit,不撤销addgit reset --mixed:工作区不变,撤销commit,撤销addgit reset --hard:删除工作区变更(危险),撤销commit,撤销add(完全回到某个commit的时候)
这后面跟着的,HEAD是指最新commit,HEAD^相当于HEAD~1,是指上一个。回到上一个的时间,就是把最新的撤回的意思。
因此,不改动文件的情况下想撤销一个commit,指令是git reset --soft HEAD^。
reset --hard和checkout都能把工作区变回当初的状态,但checkout不会修改commit历史。
总结
| 改哪里 | 变成什么内容 | 指令 |
|---|---|---|
| 工作区 | 暂存区 | git checkout -- |
| 工作区 | 版本库 | git checkout <commit> |
| 暂存区 | 工作区 | git add |
| 暂存区 | 不再追踪文件 | git rm |
| 暂存区 | 版本库 | git reset |
| 版本库 | 暂存区 | git commit |
| 版本库 | 上个版本 | git reset |
多端同步:我的代码忘在办公室了
很多时候学Git,上来就讲如何合作开发,经典的先pull再push。但俗话说,一屋不扫何以扫天下,在合作之前,我们先假设自己跟自己开发。换言之,我有同一份代码,有时候在办公室写,有时候在家写。
当然,你同样可以不用git:每天下班把代码拷回家,在家改完第二天再拷回来。如果某天忘记拷了,要么凭记忆把前一天的改动全再写一遍,要么正大光明地摸鱼一天 —— 很不elegant是吧。
Git的第二大作用就是多端同步:我办公室和家里的代码永远是同一份。从本质上,早年微软的“公文包”,近些年常见的游戏云存档,都是同一个逻辑,而Git比他们还要便捷。
我们在这里只考虑你自己,是因为你对自己的代码完全拥有主导权:没有人去改你的东西,你知道哪份是新的,用谁覆盖谁。
又一个文件版本:云端
用云存档的思维就很好理解,我们现在又多了一个版本:云端的main.cpp。以前把它叫远程仓库,现在叫云也挺合理。
- 如果本地的比较新,那把本地的上传上去,两边就同步了。
- 如果云端的比较新,那把云端的下载下来,两边就同步了。
怎么判断新旧呢?我们把commit log看成一条线,如果在一边的最后一条commit之后,另一边还有其他commits,那新旧就很明显了。
如果两边各自有不同的commit,那就不是简单的新旧关系了,我们留到后面解决。
基本操作
首次绑定:git remote
首次同步,我们需要在云端建立一个对应的Repo。GitHub是其中(最常见的)一种选择。
创建完之后,GitHub会提示你怎么做同步,跟着做就好了。
完成后,我们就能在网页端看到这个Repo了。
右侧的commit编号和本地的是一致的。
本地上云:git push
如果本地比云端新,那么简单地用git push把本地新改动同步到云端即可。
确信覆盖:git push -f
push过程中,如果云端包含了本地没有的commits,那就无法将本地Commits简单地附在Commit log最后面,会被拒绝。 广义上来说,这就是冲突处理,也是最复杂的部分,我们暂且不提。因为我们现在是个人开发,这几乎只会出现在一种情况下。
准备下班,执行了
git commit和git push,然后发现漏了一个小点,懒得再push一次,用了git commit --amend。
如果你是个新手,完全可以通过多次commit+push来避免这种情况,但是commit log会很混乱。由于git commit --amend产生的commit与已经推上云端的不同,直接push会被拒绝。
$ git-tutorial> git push |
从Git树也能看出来,目前产生了一个分叉。
由于我们非常确定本地的版本一定是最新的,以及没有人会同时修改这一份代码,所以我们直接用本地的commit覆盖掉远程的。这个操作是git push -f。推完之后,本地和远程再次统一,远程的上一个commit消失了。
作为新手,你几乎只会在这种情况下需要使用-f。
云端下载:git fetch
比起fetch,大部分人知道的是git pull。但以我的经验,盲目地pull是很麻烦的。一旦产生冲突,很多人就两手一摊,甚至重新clone一份下来另起炉灶(没错是我)。所以,养成一个好习惯,脑中把下载记成git fetch。
可以这么理解:git pull会干两件事:首先把云端的状态缓存到本地,再尝试进行merge。而git fetch只会进行前一步,不会做后续的merge,因此完全不会打乱你工作区、暂存区、本地Repo的任何文件,是个相当安全,有益无害的操作。
执行git fetch后,再用git status去查看。这时候,如果提示可以fast-forward,那就可以放心地git pull了。
我们依然默认只有你自己,所以你fetch到pull中间不会有人对云端做改动。合作开发里面这部分遇到冲突的话,我们后面解释。
$ git-tutorial> git fetch |
至此,本地代码已同步为云端代码。本地改完以后,记得再上传云端即可。
协作开发:我改了你的,你改了我的
其实掌握完上面两点之后,协作开发本质上与多端同步并没有区别。无非每个commit不仅可能来自你,也可能来自其他合作者。
如果只有两个人,最简单的方法其实是“我先改完你再改”。大家都像一个人在不同地方那样fast-forward,不会产生任何纠纷。 但是人多的情况下,总会有大家同时做同一件事的情况,甚至修改同一个文件的同一行。这时候就要考虑怎么做冲突解决了。
比较现代的开发流程里,更常见的是用rebase来取代merge。本文也以此为基础介绍。关于两者的区别可以自行了解。
以下,我们针对不同情况讲讲如何合并代码。我们假设一开始,所有人和云端的文件版本都是一致的。
站在同事的肩膀上:git stash
假设你跟同事一起开始写,你还没写完,同事已经推了个commit上去。 你想要接在同事后面继续写。
首先,我们之前说过git fetch是有益无害的,先fetch到本地看看。
我们可以看到,同事将z的值从35变成了37。而我们的改动呢?
$ git-tutorial> git diff --cached |
我们不仅有个暂存区的改动(using DATATYPE = int;),还有个没add的改动(return 0;)。此时我们尝试pull会被拒绝,因为本地的改动会被抹掉。
$ git-tutorial> git pull |
这时候,我们要做的是先用git stash把本地的改动先打包放在一边,让working tree变成无修改的状态,然后执行git pull就没什么问题了。
跟之前讲过的
git reset放弃工作区和暂存区改动有些类似,但reset是扔掉,stash是寄存包裹,后面可以去取。
$ git-tutorial> git stash |
经过stash和pull,我们的文件状态现在已经和云端同事改完的版本无异了。剩下要做的就是把我们自己改的(没commit的)找回来。这一步用git stash pop。
$ git-tutorial> git stash pop |
由于我和同事修改了同一行,Git自己不知道怎么merge,这时候就需要我们出手解决冲突了。虽然Old-school的话也可以用命令行来解冲突,不过大家都是现代人,没有放着GUI不用的道理。
打开项目的IDE,进入解冲突模块,这下应该一目了然了。左侧是当前工作区里的版本,右侧是刚刚stash pop出来的之前的改动。对于每个改动,检查下是保留还是放弃,以及上下顺序,必要时也能直接编辑。最后保留的文件已经展示在中间了。
解决所有争议,点击Apply,一次解冲突就完成了。目前的状态是:
git log中最新的commit和云端一致- 你的改动已经加入到暂存区了
如果你merge得合理,应该能无缝地继续写。记得不要把同事的改动不小心叉掉就行了。
两人都干了:git rebase
我们还回到前面那个问题,同样朋友也改了,你也改了。但是这次你不仅执行了一些add,还已经commit过了。
聪明的你马上想到:commit不是可以回退嘛?我把这些commit退回工作区,然后用上面stash的方法就行了呗。 没错,这确实是个解决方法。但是问题是,如果你有多个commit分别解决不同的问题,这样就很难再把这些commit分开来了。
可以看到,两个人从某一个commit开始产生了分歧:在云端的origin/main分支中,同事修改了z的值,而在我们本地的main分支中也有两个commit。我们希望达到什么效果呢?由于同事已经推上云了,我们的改动应该接在他后面。
我们通过git rebase来实现这一点。在当前main分支下,执行git rebase origin/main。这么做的含义是“把我们的commits接在origin/main之后”。
$ git-tutorial> git rebase origin/main |
同样我们也会遇到Git解决不了的时候。这个时候用git status检查的话,会显示代码正在rebase中。
$ git-tutorial> git status |
我们已经学过如何解决冲突了,无需多言,打开IDE。修改完后,一般而言IDE会将冲突文件自动add到暂存区,我们继续执行git rebase --continue。
$ git-tutorial> git rebase --continue |
Rebase会根据commit顺序一个一个进行。通常来说第一次rebase比较痛苦,要看共同修改的内容。后续的rebase如果不涉及同一个文件,大部分时候都能自动解决。
Rebase完成后,可以看到commit又变成一条线了,我们的修改自然地接在同事的之后。这样我们后续的push就是fast-forward,不会被阻拦了。
人再多一些:git branch
尽管我们学习了如何处理冲突,在人多的情况下,互相修改还是很容易导致代码出现问题,并拖慢开发效率。现代开发流程中,常见做法是每个人在自己分支上干一件事,全部开发完成后再合入主分支。
比如我们现在要修一个bug,那我们先切一个分支出来。指令是git checkout -b <name>
$ git-tutorial> git checkout -b fix_add_function |
这样,即使在主分支上有其他修改,我们也暂时不用理会,可以从自己分出的版本继续开发,后续再考虑合并的事情。如果要切换分支,可以用git checkout <name>来进行。这也可以用于在不同版本代码之间快速跳转。
我们需要在远程设置一个分支来追踪我们的新分支,git会提示我们:
$ git-tutorial> git push --set-upstream origin fix_add_function |
同时,为我们创建了一个合入主分支的链接,点击即可。
我们可以通过这个链接创建一个Merge Request(MR),在简介中写明改动的目的、主要代码逻辑变动等。经过一系列合入流程(如单测、集测、Code Review)之后,就可以合入主代码,完成这一分支的任务了。
总结
以上是最最基础的Git用法,但基本能应对大部分场景了。其他关于tag、cherrypick、submodule等等概念,有了上面的基础,用到的时候现学就会了。