本文为可视化Git学习项目的学习笔记。初级操作可视化学习:Learn Git Branching

项目地址:pcottle/learnGitBranching: An interactive git visualization and tutorial. Aspiring students of git can use this app to educate and challenge themselves towards mastery of git! (github.com)

Git文档:Git - Documentation (git-scm.com)

Git基本操作

基本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
git commit # 新建提交记录
git log # 查看提交记录的hash值

git branch <branch_name> # 新建分支
git checkout <branch/node> # 切换分支或node
git checkout -b <your-branch-name> # 新建并切换分支

# 分支合并
git merge <branch_name> # 合并branch_name分支到当前分支
git rebase <branch_name> # 把当前分支里的工作直接移到branch_name上

# 自由在提交树移动(支持链式操作)
# 相对引用
^ # 向上移动一个提交记录
^^ # 向上移动两个提交记录
~<num> # 向上移动多个提交记录

# 选择父提交记录
^<num> # 指定合并提交记录的某个父提交。(一个合并提交有两个父提交)

git branch -f <branch> <node> # 强制修改branch分支位置
# e.g. git branch -f main HEAD~3

Rebase

Rebase 实际上就是取出一系列的提交记录,“复制”它们,然后在另外一个地方逐个的放下去。

Rebase 的优势就是可以创造更线性的提交历史,这听上去有些难以理解。如果只允许使用 Rebase 的话,代码库的提交历史将会变得异常清晰。

HEAD 是一个对当前所在分支的符号引用 —— 也就是指向你正在其基础上进行工作的提交记录。

HEAD 总是指向当前分支上最近一次提交记录。大多数修改提交树的 Git 命令都是从改变 HEAD 的指向开始的。

HEAD 通常情况下是指向分支名的(用*表示)。分离的 HEAD 就是让其指向了某个具体的提交记录而不是分支名。

撤销变更

1
git reset <node>  

通过把分支记录回退几个提交记录来实现撤销改动(对远程分支无效)。

例如:在C2处执行git reset HEAD~1

image-20230527163230995

1
git revert <node> 

例如:在C2处执行git revert HEAD

image-20230527163039945

要撤销的提交记录后面多了一个新提交。新提交记录 C2’ 引入了更改 —— 这些更改刚好是用来撤销 C2 这个提交的。也就是说 C2’ 的状态与 C1 是相同的。revert 之后就可以把你的更改推送到远程仓库。

修改分支树

1
git cherry-pick <node>...

将一些提交复制到当前所在位置。cherry-pick 可以将提交树上任何地方的提交记录取过来追加到 HEAD 上(只要不是 HEAD 上游的提交就没问题)。

1
git rebase --interactive <node>

如果你在命令后增加了这个选项, Git 会打开一个 UI 界面并列出将要被复制到目标分支的备选提交记录,它还会显示每个提交记录的哈希值和提交说明,提交说明有助于你理解这个提交进行了哪些更改。

可以进行以下操作:

  • 调整记录顺序

  • 删除不想要的提交

  • 合并提交

本地栈式提交

  • 一个在开发中经常会遇到的情况:我正在解决某个特别棘手的 Bug,为了便于调试而在代码中添加了一些调试命令并向控制台打印了一些信息。这些调试和打印语句都在它们各自的提交记录里。最后我终于找到了造成这个 Bug 的根本原因。最后就差把 bugFix 分支里的工作合并回 main 分支了。你可以选择通过 fast-forward 快速合并到 main 分支上,但这样的话 main 分支就会包含我这些调试语句了。你肯定不想这样,应该还有更好的方式……
    答案:使用上述命令修改分支树

  • 接下来这种情况也是很常见的:你之前在 newImage 分支上进行了一次提交,然后又基于它创建了 caption 分支,然后又提交了一次。此时你想对某个以前的提交记录进行一些小小的调整。比如设计师想修改一下 newImage 中图片的分辨率,尽管那个提交记录并不是最新的了。

    image-20230527165217947
    答案:我们可以通过下面的方法来克服困难:

    • 先用 git rebase -i 将提交重新排序,然后把我们想要修改的提交记录挪到最前
    • 然后用 git commit --amend 来进行一些小修改
    • 接着再用 git rebase -i 来将他们调回原来的顺序
    • 最后我们把 main 移到修改的最前端(用你自己喜欢的方法)

    但这样做就唯一的问题就是要进行两次排序,而这有可能造成由 rebase 而导致的冲突。尝试使用cherry-pick。

Tag与Describe

1
git tag <your_tag> [node]

分支很容易被人为移动,并且当有新的提交时,它也会移动。分支很容易被改变,大部分分支还只是临时的,并且还一直在变。

有没有什么可以永远指向某个提交记录的标识呢,比如软件发布新的大版本,或者是修正一些重要的 Bug 或是增加了某些新特性,有没有比分支更好的可以永远指向这些提交的方法呢?

Git 的 tag 可以(在某种程度上 —— 因为标签可以被删除后重新在另外一个位置创建同名的标签)永久地将某个特定的提交命名为里程碑,然后就可以像分支一样引用了。

更难得的是,它们并不会随着新的提交而移动。你也不能切换到某个标签上面进行修改提交,它就像是提交树上的一个锚点,标识了某个特定的位置。

1
2
3
git describe <ref>
# <ref> 可以是任何能被 Git 识别成提交记录的引用,如果你没有指定的话,Git 会使用你目前所在的位置(HEAD)。
# 输出结果:<tag>_<numCommits>_g<hash>

由于标签在代码库中起着“锚点”的作用,Git 还为此专门设计了一个命令用来描述离你最近的锚点(也就是标签),它就是 git describe

Git Describe 能帮你在提交历史中移动了多次以后找到方向;当你用 git bisect(一个查找产生 Bug 的提交记录的指令)找到某个提交记录时,或者是当你坐在你那刚刚度假回来的同事的电脑前时, 可能会用到这个命令。

远程仓库操作

基本操作

远程仓库实际上它们只是你的仓库在另个一台计算机上的拷贝。

远程分支反映了远程仓库(在你上次和它通信时)的状态。这会有助于你理解本地的工作与公共工作的差别。

远程分支有一个特别的属性,在你切换到远程分支时,自动进入分离 HEAD 状态。Git 这么做是出于不能直接在这些分支上进行操作的原因, 你必须在别的地方完成你的工作, (更新了远程分支之后)再用远程分享你的工作成果。

image-20230527173159602

远程分支有一个命名规范 —— 它们的格式是<remote name>/<branch name>

1
git fetch

git fetch 完成了仅有的但是很重要的两步:

  • 从远程仓库下载本地仓库中缺失的提交记录
  • 更新远程分支指针(如 origin/main)

git fetch 实际上将本地仓库中的远程分支更新成了远程仓库相应分支最新的状态。git fetch 并不会改变你本地仓库的状态。它不会更新你的 main 分支,也不会修改你磁盘上的文件。理解这一点很重要,因为许多开发人员误以为执行了 git fetch 以后,他们本地仓库就与远程仓库同步了。它可能已经将进行这一操作所需的所有数据都下载了下来,但是并没有修改你本地的文件。所以, 你可以将 git fetch 的理解为单纯的下载操作。

1
2
3
4
git pull
# 相当于以下两条命令,pull 就是 fetch + merge
git fetch
git merge origin/main

实际上,由于先抓取更新再合并到本地分支这个流程很常用,因此 Git 提供了一个专门的命令来完成这两个操作。

1
git push

git push 负责将你的变更上传到指定的远程仓库,并在远程仓库上合并你的新提交记录。git push 不带任何参数时的行为与 Git 的一个名为 push.default 的配置有关。它的默认值取决于你正使用的 Git 的版本,但是在教程中我们使用的是 upstream

冲突处理

假设你周一克隆了一个仓库,然后开始研发某个新功能。到周五时,你新功能开发测试完毕,可以发布了。但是 —— 天啊!你的同事这周写了一堆代码,还改了许多你的功能中使用的 API,这些变动会导致你新开发的功能变得不可用。但是他们已经将那些提交推送到远程仓库了,因此你的工作就变成了基于项目旧版的代码,与远程仓库最新的代码不匹配了。

image-20230527175900652

这种情况下, git push 就不知道该如何操作了。如果你执行 git push,Git 应该让远程仓库回到星期一那天的状态吗?还是直接在新代码的基础上添加你的代码,亦或由于你的提交已经过时而直接忽略你的提交?

因为这情况(历史偏离)有许多的不确定性,Git 是不会允许你 push 变更的。实际上它会强制你先合并远程最新的代码,然后才能分享你的工作。

那该如何解决这个问题呢?很简单,你需要做的就是使你的工作基于最新的远程分支。有许多方法做到这一点呢,不过最直接的方法就是通过 rebase 调整你的工作:

1
2
3
4
git fetch
git rebase o/main
git push
# 相当于 git pull --rebase; git push

origin_rebase

我们还可以使用 merge

尽管 git merge 不会移动你的工作(它会创建新的合并提交),但是它会告诉 Git 你已经合并了远程仓库的所有变更。这是因为远程分支现在是你本地分支的祖先,也就是说你的提交已经包含了远程分支的所有变化:

1
2
3
4
git fetch
git merge o/main
git push
# 相当于 git pull; git push;

origin_merge

在开发社区里,有许多关于 merge 与 rebase 的讨论。以下是关于 rebase 的优缺点:

优点:Rebase 使你的提交树变得很干净, 所有的提交都在一条线上

缺点:Rebase 修改了提交树的历史

选择哪个,仁者见仁智者见智。

远程跟踪分支

跟踪:

  • pull 操作时,提交记录会被先下载到 origin/main 上,之后再合并到本地的 main 分支。隐含的合并目标由这个关联确定的。
  • push 操作时,我们把工作从 main 推到远程仓库中的 main 分支(同时会更新远程分支 origin/main) 。这个推送的目的地也是由这种关联确定的!

当你克隆时, Git 会为远程仓库中的每个分支在本地仓库中创建一个远程分支(比如 origin/main)。然后再创建一个跟踪远程仓库中活动分支的本地分支,默认情况下这个本地分支会被命名为 main

我们可以自己指定这种属性:

有两种方法:

第一种就是通过远程分支切换到一个新的分支,执行:

1
git checkout -b totallyNotMain origin/main

就可以创建一个名为 totallyNotMain 的分支,它跟踪远程分支 origin/main

第二种工作方法:

另一种设置远程追踪分支的方法就是使用:git branch -u 命令,执行:

1
git branch -u origin/main foo # 如果当前就在 foo 分支上, 还可以省略 foo

Git Push的参数

1
git push <remote> <place>

例子:git push origin main

含义:*切到本地仓库中的“main”分支,获取所有的提交,再到远程仓库“origin”中找到“main”分支,将远程仓库中没有的提交记录都添加上去,搞定之后告诉我。*我们通过“place”参数来告诉 Git 提交记录来自于 main, 要推送到远程仓库中的 main。它实际就是要同步的两个仓库的位置。需要注意的是,因为我们通过指定参数告诉了 Git 所有它需要的信息,所以它就忽略了我们所切换分支的属性(就是说不管你的HEAD在哪)!

当为 git push 指定 place 参数为 main 时,我们同时指定了提交记录的来源去向。要同时为源和目的地指定 <place> 的话,只需要用冒号 : 将二者连起来就可以了:

1
git push origin <source>:<destination>

这个参数实际的值是个 refspec,“refspec” 是一个自造的词,意思是 Git 能识别的位置(比如分支 foo 或者 HEAD~1)。如果你要推送到的目的分支不存在,Git 会在远程仓库中根据你提供的名称帮你创建这个分支。

Git Fetch的参数

git fetch 的参数和 git push 极其相似。他们的概念是相同的,只是方向相反罢了(因为现在你是下载,而非上传)如果你像如下命令这样为 git fetch 设置 的话:

1
git fetch origin foo

Git 会到远程仓库的 foo 分支上,然后获取所有本地不存在的提交,放到本地的 origin/foo 上。

“如果我们指定 <source>:<destination> 会发生什么呢?”

如果你觉得直接更新本地分支很爽,那你就用冒号分隔的 refspec 吧。不过,你不能在当前切换的分支上干这个事,但是其它分支是可以的。

这里有一点是需要注意的 —— source 现在指的是远程仓库中的位置,而 <destination> 才是要放置提交的本地仓库的位置。它与 git push 刚好相反,这是可以讲的通的,因为我们在往相反的方向传送数据。

理论上虽然行的通,但开发人员很少这么做。我在这里介绍它主要是为了从概念上说明 fetchpush 的相似性,只是方向相反罢了。

如果执行命令前目标分支不存在,Git 会在 fetch 前自己创建立本地分支,就像是 Git 在 push 时,如果远程仓库中不存在目标分支,会自己在建立一样。

如果 git fetch 没有参数,它会下载所有的提交记录到各个远程分支。

不指定<source>

在 git push 或 git fetch 时不指定任何 source,方法就是仅保留冒号和 destination 部分,source 部分留空。

例如:git push origin :sidegit fetch origin :bugFix

  • 给 push 传空值 source,成功删除了远程仓库中的 side 分支

  • 如果 fetch 空 到本地,会在本地创建一个新分支。

Git Pull

1
2
git pull origin foo # git fetch origin foo; git merge origin/foo
git pull origin bar~1:bugFix # git fetch origin bar~1:bugFix; git merge bugFix