本地仓库
练习网站:https://learngitbranching.js.org/?locale=zh_CN
reset
可以重新开始
Esc
可以跳过讲解。
undo
可以撤销
hint
显示提示指令
objective
是目标
分支与合并
分支
主线是main,需要创建一个新的分支bugfix,同时切换到这个bugfix分支
git branch bugfix
创建bugfix分支git checkout bugfix
切换到bugfix分支git checkout -b bugfix
:相比上面的操作更加简洁
合并
上图的两个分支中,每个分支各有一个独立的提交,表明没有任何一个分支包含我们修改的全部内容。我们可以通过合并操作解决这个问题
方法1:git merge
git merge bugfix
:把bugfix分支合并到当前主线main分支里
main现在指向一个拥有两个父节点的提交记录;假如从 main
开始沿着箭头向上看,在到达起点的路上会经过所有的提交记录。这意味着 main
包含了对代码库的所有修改
每个分支都有不同的颜色,而每个提交记录的颜色是所有包含该提交记录的分支的颜色混合之后的颜色。所以,main
分支的颜色被混入到所有的提交记录,但 bugFix
没有。下面咱们让它也改变一下颜色。
git checkout bugfix
:将当前主线切换到bugfix分支
git merge main
:将main分支合并到主线bugfix分支中
因为min分支继承自bugfix,那么Git什么都不用做,只需要把bugfix移动到main指向的那个提交记录即可。
现在所有提交记录的颜色都一样了,这表明每一个分支都包含了代码库的所有修改
方法2:git rebase
Rebase 实际上就是取出一系列的提交记录,“复制”它们,然后在另外一个地方逐个的放下去。
Rebase 的优势就是可以创造更线性的提交历史,这听上去有些难以理解。如果只允许使用 Rebase 的话,代码库的提交历史将会变得异常清晰。
还是准备了两个分支;注意当前所在的分支是 bugFix(星号标识的是当前分支)
我们想要把 bugFix 分支里的工作直接移到 main 分支上。移动以后会使得两个分支的功能看起来像是按顺序开发,但实际上它们是并行开发的。
git rebase main
:将main分支合并到主线bugfix分支
注意,提交记录 C3 依然存在(树上那个半透明的节点),而 C3' 是我们 Rebase 到 main 分支上的 C3 的副本。
现在唯一的问题就是 main 还没有更新,下面咱们就来更新它吧……
git checkout main
git rebase bugfix
在项目树上的移动
HEAD
HEAD是checkout的灵魂,实现切换分支与新建分支的主要功臣就是在.git目录下的HEAD引用
可以通过
cat .git/HEAD
查看HEAD的指向,如果 HEAD 指向的是一个引用,还可以用git symbolic-ref HEAD
查看它的指向
HEAD默认指向分支名, 而分支名指向最近一次提交,都随着提交前进
分离的HEAD
分离的 HEAD 就是让其指向了某个具体的提交记录而不是分支名
在命令执行之前的状态如下所示:
HEAD -> main -> C1
HEAD 指向 main, main 指向 C1
执行 git checkout c1
相对引用
通过哈希值指定提交记录很不方便,所以 Git 引入了相对引用
使用相对引用的话,你就可以从一个易于记忆的地方(比如 bugFix
分支或 HEAD
)开始计算。
相对引用非常给力,这里我介绍两个简单的用法:
- 使用
^
向上移动 1 个提交记录 - 使用
~<num>
向上移动多个提交记录,如~3
操作符^
首先看看操作符 (^)。把这个符号加在引用名称的后面,表示让 Git 寻找指定提交记录的父提交。
所以 main^
相当于“main
的父节点”。
main^^
是 main
的第二个父节点
现在咱们切换到 main 的父节点
git checkout main^
操作符~
如果你想在提交树中向上移动很多步的话,敲那么多 ^
貌似也挺烦人的,Git 当然也考虑到了这一点,于是又引入了操作符 ~
。
该操作符后面可以跟一个数字(可选,不跟数字时与 ^
相同,向上移动一次),指定向上移动多少次。
强制修改分支位置
可以直接使用 -f
选项让分支指向另一个提交。例如:
git branch -f main HEAD~3
上面的命令会将 main 分支强制指向 HEAD 的第 3 级父提交。
撤销变更
在 Git 里撤销变更的方法很多。和提交一样,撤销变更由底层部分(暂存区的独立文件或者片段)和上层部分(变更到底是通过哪种方式被撤销的)组成。我们这个应用主要关注的是后者。
主要有两种方法用来撤销变更 —— 一是 git reset
,还有就是 git revert
。
git reset
git reset
通过把分支记录回退几个提交记录来实现撤销改动。你可以将这想象成“改写历史”。git reset
向上移动分支,原来指向的提交记录就跟从来没有提交过一样
git reset HEAD~1
Git 把 main 分支移回到 C1
;现在我们的本地代码库根本就不知道有 C2
这个提交了
在reset后, C2
所做的变更还在,但是处于未加入暂存区状态
git revert
虽然在你的本地分支中使用 git reset
很方便,但是这种“改写历史”的方法对大家一起使用的远程分支是无效的哦!
为了撤销更改并分享这次撤销给别人,我们需要使用 git revert
。
git revert HEAD
在我们要撤销的提交记录后面居然多了一个新提交!这是因为新提交记录 C2'
引入了更改 —— 这些更改刚好是用来撤销 C2
这个提交的。也就是说 C2'
的状态与 C1
是相同的。
revert 之后就可以把你的更改推送到远程仓库与别人分享啦。
整理提交记录
git cherry-pick <提交号>
如果你想将一些提交复制到当前所在的位置(
HEAD
)下面(且必须当前分支上没有这些提交)的话, Cherry-pick 是最直接的方式了cherry-pick:择优挑选
这里有一个仓库, 我们想将 side
分支上的工作复制到 main
分支
git cherry-pick c2 c4
我们只需要提交记录 C2
和 C4
,所以 Git 就将它们抓过来放到当前分支下了。
交互式的rebase
当你知道你所需要的提交记录(并且还知道这些提交记录的哈希值)时, 用 cherry-pick 再好不过了 —— 没有比这更简单的方式了。
但是如果你不清楚想要的提交记录的哈希值呢? 幸好 Git 帮你想到了这一点, 我们可以利用交互式的 rebase —— 如果你想从一系列的提交记录中找到想要的记录, 这就是最好的方法了
交互式 rebase 指的是使用带参数 --interactive
的 rebase 命令, 简写为 -i
如果你在命令后增加了这个选项, Git 会打开一个 UI 界面并列出将要被复制到目标分支的备选提交记录,它还会显示每个提交记录的哈希值和提交说明,提交说明有助于你理解这个提交进行了哪些更改。
在实际使用时,所谓的 UI 窗口一般会在文本编辑器 —— 如 Vim 中打开一个文件。
当 rebase UI界面打开时, 你能做3件事:
- 调整提交记录的顺序(通过鼠标拖放来完成)
- 删除你不想要的提交(通过切换
pick
的状态来完成,关闭就意味着你不想要这个提交记录) - 合并提交。 它允许你把多个提交记录合并成一个
git rebase -i HEAD~4
小技巧
只取一个提交记录
来看一个在开发中经常会遇到的情况:我正在解决某个特别棘手的 Bug,为了便于调试而在代码中添加了一些调试命令并向控制台打印了一些信息。
这些调试和打印语句都在它们各自的提交记录里。最后我终于找到了造成这个 Bug 的根本原因,解决掉以后觉得沾沾自喜!
最后就差把 bugFix
分支里的工作合并回 main
分支了。你可以选择通过 fast-forward 快速合并到 main
分支上,但这样的话 main
分支就会包含我这些调试语句了
实际我们只要让 Git 复制解决问题的那一个提交记录就可以了。跟之前我们在“整理提交记录”中学到的一样,我们可以使用
git rebase -i
git cherry-pick
来达到目的。
提交技巧1
git rebase -i
你之前在 newImage
分支上进行了一次提交,然后又基于它创建了 caption
分支,然后又提交了一次。
此时你想对某个以前的提交记录进行一些小小的调整。比如设计师想修改一下 newImage
中图片的分辨率,尽管那个提交记录并不是最新的了。
我们可以通过下面的方法来克服困难:
- 先用
git rebase -i
将提交重新排序,然后把我们想要修改的提交记录挪到最前 - 然后用
git commit --amend
来进行一些小修改
git commit --amend命令用来修复最近一次commit. 可以让你合并你缓存区的修改和上一次commit, 而不是提交一个新的快照. 还可以用来编辑上一次的commit描述. 记住amend不是修改最近一次commit, 而是整个替换掉他. 对于Git来说是一个新的commit - 接着再用
git rebase -i
来将他们调回原来的顺序 - 最后我们把 main 移到修改的最前端(用你自己喜欢的方法),就大功告成啦!
当然完成这个任务的方法不止上面提到的一种(我知道你在看 cherry-pick 啦),最后有必要说明一下目标状态中的那几个'
—— 我们把这个提交移动了两次,每移动一次会产生一个 '
;而 C2 上多出来的那个是我们在使用了 amend 参数提交时产生的,所以最终结果就是这样了。
也就是说,我在对比结果的时候只会对比提交树的结构,对于 '
的数量上的不同,并不纳入对比范围内。只要你的 main
分支结构与目标结构相同,我就算你通过。
提交技巧2
git cherry-pick
cherry-pick 可以将提交树上任何地方的提交记录取过来追加到 HEAD 上(只要不是 HEAD 上游的提交就没问题)
git tag
通过前面的学习,我们发现分支很容易被人为移动,并且当有新的提交时,它也会移动。分支很容易被改变,大部分分支还只是临时的,并且还一直在变。
那么有没有什么可以永远指向某个提交记录的标识呢,比如软件发布新的大版本,或者是修正一些重要的 Bug 或是增加了某些新特性,有没有比分支更好的可以永远指向这些提交的方法呢?
Git 的 tag 就是干这个用的啊,它们可以(在某种程度上 —— 因为标签可以被删除后重新在另外一个位置创建同名的标签)永久地将某个特定的提交命名为里程碑,然后就可以像分支一样引用了。
更难得的是,它们并不会随着新的提交而移动。你也不能检出到某个标签上面进行修改提交,它就像是提交树上的一个锚点,标识了某个特定的位置。
咱们先建立一个标签,指向提交记录 C1
,表示这是我们 1.0 版本。如果不指定提交记录,Git 会用 HEAD
所指向的位置。
git tag v1 c1
git describe
由于标签在代码库中起着“锚点”的作用,Git 还为此专门设计了一个命令用来描述离你最近的锚点(也就是标签),它就是 git describe
!
Git Describe 能帮你在提交历史中移动了多次以后找到方向;当你用 git bisect
(一个查找产生 Bug 的提交记录的指令)找到某个提交记录时,或者是当你坐在你那刚刚度假回来的同事的电脑前时, 可能会用到这个命令。
git describe
的语法是:
git describe <ref>
<ref>
可以是任何能被 Git 识别成提交记录的引用,如果你没有指定的话,Git 会以你目前所检出的位置(HEAD
)。
它输出的结果是这样的:
<tag>_<numCommits>_g<hash>
tag
表示的是离 ref
最近的标签, numCommits
是表示这个 ref
与 tag
相差有多少个提交记录, hash
表示的是你所给定的 ref
所表示的提交记录哈希值的前几位。
当 ref
提交记录上有某个标签时,则只输出标签名称
执行git tag v2 c3
命令后:
执行git describe main
会输出:
v1_2_gC2
执行git describe side
会输出:
v2_1_gC4
远程仓库
push & pull
远程分支
远程分支有一个特别的属性,在你检出(checkout)时自动进入分离 HEAD 状态(HEAD指向一个提交记录而不是分支)。
Git 这么做是出于不能直接在这些分支上进行操作的原因, 你必须在别的地方完成你的工作, (更新了远程分支之后)再用远程分享你的工作成果。
远程分支有一个命名规范 —— 它们的格式是:
<remote name>/<branch name>
因此,如果你看到一个名为 o/main
的分支,那么这个分支就叫 main
,远程仓库的名称就是 o
。
大多数的开发人员会将它们主要的远程仓库命名为 origin
,并不是 o
。这是因为当你用 git clone
某个仓库时,Git 已经帮你把远程仓库的名称设置为 origin
了
git checkout o/main
git commit
执行之后,Git 变成了分离 HEAD 状态,当添加新的提交时 o/main
也不会更新。这是因为 o/main
只有在远程仓库中相应的分支更新了以后才会更新。
git fetch
拉取远程仓库的更新到本地的远程分支
Git 远程仓库相当的操作实际可以归纳为两点:向远程仓库传输数据以及从远程仓库获取数据。
既然我们能与远程仓库同步,那么就可以分享任何能被 Git 管理的更新(因此可以分享代码、文件、想法、情书等等)。
如何从远程仓库获取数据 —— 命令如其名,它就是 git fetch
。
上图中有一个远程仓库,它有2个我们本地仓库中没有的
执行git fetch
之后,C2
,C3
被下载到了本地仓库,同时远程分支 o/main
也被更新
git fetch做的事情
git fetch
完成了仅有的但是很重要的两步:
- 从远程仓库下载本地仓库中缺失的提交记录
- 更新远程分支指针(如
o/main
)
git fetch
实际上将本地仓库中的远程分支更新成了远程仓库相应分支最新的状态。
如果你还记得上一节课程中我们说过的,远程分支反映了远程仓库在你最后一次与它通信时的状态,git fetch
就是你与远程仓库通信的方式了!
git fetch
通常通过互联网(使用 http://
或 git://
协议) 与远程仓库通信。
git fetch不会做的事情
git fetch
并不会改变你本地仓库的状态。它不会更新你的 main
分支,也不会修改你磁盘上的文件。
理解这一点很重要,因为许多开发人员误以为执行了 git fetch
以后,他们本地仓库就与远程仓库同步了。它可能已经将进行这一操作所需的所有数据都下载了下来,但是并没有修改你本地的文件。
所以,可以将 git fetch
的理解为单纯的下载操作。
git pull
当远程分支中有新的提交时,你可以像合并本地分支那样来合并远程分支。也就是说就是你可以执行以下命令:
git cherry-pick o/main
git rebase o/main
git merge o/main
- 等等
实际上,由于先抓取更新再合并到本地分支这个流程很常用,因此 Git 提供了一个专门的命令来完成这两个操作。它就是我们要讲的 git pull
。
我们先来看看 fetch
、merge
依次执行的效果
git fetch
:将远程仓库的新提交拉取到本地的远程分支
git merge o/main
:将远程分支o/main合并到主线分支main
我们用 fetch
下载了 C3
, 然后通过 git merge o/main
合并了这一提交记录。现在我们的 main
分支包含了远程仓库中的更新(在本例中远程仓库名为 origin
)
git pull包含的简写
git pull
就是 git fetch 和 git merge 的缩写git pull --rebase
就是 fetch 和 rebase 的简写
git push
git push
不带任何参数时的行为与 Git 的一个名为 push.default
的配置有关。它的默认值取决于你正使用的 Git 的版本,但是在教程中我们使用的是 upstream
。 这没什么太大的影响,但是在你的项目中进行推送之前,最好检查一下这个配置
git push
远程仓库接收了 C2
,远程仓库中的 main
分支也被更新到指向 C2
了,我们的远程分支 (o/main) 也同样被更新了。所有的分支都同步了!
远程服务器拒绝(Remote Rejected)
如果你是在一个大的合作团队中工作, 很可能是main被锁定了, 需要一些Pull Request流程来合并修改。如果你直接提交(commit)到本地main, 然后试图推送(push)修改, 你将会收到这样类似的信息:
! [远程服务器拒绝] main -> main (TF402455: 不允许推送(push)这个分支; 你必须使用pull request来更新这个分支.)
远程服务器拒绝直接推送(push)提交到main, 因为策略配置要求 pull requests 来提交更新.
你应该按照流程,新建一个分支, 推送(push)这个分支并申请pull request,但是你忘记并直接提交给了main.现在你卡住并且无法推送你的更新.