git 是最著名的版本管理软件,其最大作用就是离线保存项目的完整编辑经过/历史,并提供了同步,从而能够实现

  • 尽可能离线的多人协作和代码同步
  • 回滚
  • 标注稳定/纪念性版本(增加成就感?)
  • 分支和合并

本文旨在记录学习到的 git 本地基础知识以复习。初学者建议学习 Pro Git book,并借用 Learn git branching 网站形成形象的理解。

约定:“某个”/“特定的”特指在前面的命令中提到的那个。

前言

过去,我对 git 的使用只是单纯的 clone 然后只读地作为库使用它;或者是在单人开发中 add, commit push 一把梭。这时 git 仅仅作为一种比 rsync 更加麻烦的同步工具而被使用,然而其 commit 树带来的众多优秀的本地特性才是 git 的精髓。

若要真正熟练运用 git 这个强大工具,不但要对 commit, branch 等概念有基本的理解,更要理解 git 的数据结构和基本原理。

入门

git init # 初始化一个仓库
# 一通编辑
git add . # 添加当前目录所有文件到 git 暂存区
git commit -m <commit message> # commit

基本概念

.git 目录记录了这个项目编辑改动的全部历史。

commit 指一次提交记录,branch 是一串提交记录。他们保存了一个项目编辑的全部历史经过。

(逻辑上)一个项目中有一个主分支(通常称为 master/main),其他分支都是从主分支中某个 commit 中分叉出来的,即产生了与主分支不同的 commit。

当然每个分支事实上的地位是平等的,任何分支都可以产生子分支,这正如 git 图标一样。branch 之间也可以有很复杂的交叉结构。

git

branch 之间在不冲突、没有对同一地方做不同修改时可以合并。

主体结构:commit, HEAD 和 branch

*commit 作为动词时指 git commit 这一命令,而作为名词时指一个 git commit 产生记录对应的链表节点。

一个项目的 git “历史”用一个单链表表示,它以最新 commit 为链表头,用哈希值命名,可以用 tag 起别名;结构上每个 commit 指向它的更旧的那个 commit。一个 commit 通过 git commit 命令创建。

每一次 commit 指令中 git 都会把目录真实状态与最新 commit 进行对比,将 diff 作为 commit 内容储存下来,然后创建一个更新的 commit。

commit 也可以用相对位置表示。由于单链表的单向性,一个 commit 只能表示其历史 commit。<commit>^表示 commit 的前一 commit,<commit>^^...^(n个^)表示 commit 向前 n 个 commit,<commit>~n 也表示 commit 向前 n 个 commit。

对于 commit 列表,用 <commit-start>...<commit-end> 表示,两头都包括。


HEAD 是一个特殊的 commit 指针:它表示当下处在的链表节点/commit 状态,在进行 commit 的时候 HEAD 会自动指向更新的 commit;它也可以随着我们的指令移动到任何 commit,此时所有的(或是指定的)文件会恢复到那个 commit 的状态。

任何一次 commit 操作都会生成新的节点指向目前的 HEAD,并把 HEAD 指向新节点。当 HEAD 是真正链表头时,这相当于延长了链表;而并非链表头时,这会产生分叉。


尽管 branch 在理念上是 git 的基本组成,在底层算法和实际使用中它只是一个附属物。它有两个特性:

  • 指向某个 commit
  • 能附加在 HEAD 上随之更新移动

git 中任何以 commit 为参数的命令也都可以以 branch 为参数,git 会自动转化为 branch 指向的 commit。

在实际应用中,branch 一般是作为所有 commit 链表头的命名标注而存在(毕竟如果忘记了一个 commit 链表头那么这个链表就再也找不到了)。当然,理论上即使没有任何 branch, git 也能工作,只要你自己记得所有 commit 树链表头就行。 (人工 branch 了属于是,但是 git gc 会给你颜色看)

当没有任何 branch 附着在 HEAD 上时(分离 HEAD 模式),我们可以把这种状态当作我们在处理一个匿名 branch;这个 branch 可以通过事后通过 git branch 命名。需要注意,git gc 会自动删除无用的 commit,包括很久都没有使用的、没有被任何 branch 包括的 commit。让我们看看 git 命令行对这一状态给出的说明:

You are in ‘detached HEAD’ state. You can look around, make experimental changes and commit them, and you can discard any commits you make in this state without impacting any branches by switching back to a branch.

checkout 和 reset:HEAD 的奇幻之旅

通过 git checkout <branch>,HEAD 会移动到对应 commit 处,并被对应 branch 附着,从而编辑并更新那一个 branch。🍭语法糖:新建一个 branch 并切换过去:git checkout -b <branch>

另一种 checkout 的使用方式是 git checkout <commit>,将 HEAD 移动到某个 commit 节点处。这时就会以分离 HEAD 模式工作。

尤其注意,在任何 checkout 操作前,若在最新 commit 后对文件有编辑,需要通过再次 commit 保存更改再 checkout;否则会丢失所有更改。

git checkout <commit> <files...> 只恢复特定文件到某 commit。

reset 会把 HEAD 连同其附着的 branch 移动到某 commit,且不会修改本地目录文件。什么?你要移动 branch 并改动文件?建议 branch 删了重建。移动 branch 的操作不符合 branch 的语义。

警告:reset 只有在本地开发误 commit 而尚未同步到中心化储存时可以用来作为补救。

merge 和 rebase:分支的整合

*这里会大量用到 commit1 和 commit 2 的最新共同历史 commit,下文中记为 hcommit。

merge 用来整合分支。git merge <commit> 操作会生成一个 commit,它把某个 commit 相较于 hcommit 做出的改动合并到 HEAD,并为 HEAD 生成一个新 merge commit,它与普通 commit 的不同在于它拥有两个父 commit。在对于 merge commit 做 ^ 操作时,默认选择原来的 HEAD 作为历史 commit;或者通过 <commit>^2 来选择另一个历史 commit。

理想状态下,HEAD 改动的和某个 commit 改动的应该是不同文件或者同一文件的不同部分,那么 git 会进行三方合并:找到两个待合并 commit 的公共历史节点 hcommit,两个 commit 的每个文件每一行分别与它 diff,若一个改动了一个没改动,则改动部分为新 commit 的改动部分。若都改动了——冲突了!解决方式详见 冲突解决

注意,merge 之后,只是单纯在 HEAD 后生成了一个新 commit,原来的 commit 以及它的历史节点都没有消失。

有一个更加理想的状态:HEAD 正巧是那个 commit 的历史节点,这意味着 HEAD 本身就是一个公共历史节点,HEAD 与 HEAD 对比当然没有改动,故 HEAD 会直接移动到那个commit。这个过程被称作“快速前进”(Fast-forward)。这与上述 merge “新生成一个 commit” 的表现不统一,但是合情合理。


如果说 merge 是合并过来,那么 rebase 就是合并过去,但是所有 commit 逐个移动,而非生成单独一个 commit;从而把当前 branch 的commit 全部移动到某个 branch 上,仿佛所有当前 branch 上的 commit 都是对目标 branch 提交的一样,从而生成一个没有多余分支的“线性”的目标 branch。

git rebase <commit> 会把 HEAD 到共同 hcommit 之间所有 commit 都移动到 commit 之后,包括 HEAD (和附着的 branch)本身。(当然工作原理上是复制——git 不会第一时间删除任何东西,以保留撤销操作的可能)

另一种用法是 git rebase <commit1> <commit2>,这会把 commit2 到 hcommit 之间部分移动到 commit1 之后。

类似于 merge,如果 commit 正巧是 HEAD 的历史节点,git 会快速前进。如果有冲突,会进入冲突处理流程。

一种实践是当你在完成某个 branch 之后要把它合并到一个主线 branch(main),你可以通过 git rebase main 把所有当前 branch 的 commit 移动到主线上,再 git checkout main && git merge <branch>,好似你一直在 main branch 上 commit(有的人很喜欢这种干净的感觉,但也有人认为这违背了 git 记录历史的原旨)。

更神奇的用法是 git rebase -i <commit> (<commit2>):它可以让你用可交互界面交换、删除或合并 HEAD(包括) 到 commit 之间(不包括)的 commits,并整体移动到 commit2 上;若无 commit2 则 commit2=commit。对于 branch 强迫症患者来说这是最美妙的武器了。

警告:不要在任何未完成其使命的 branch 上使用 rebase,不要对远程库上已有的 commit rebase。

revert 和 cherry-pick:玩一玩 diff

git revert <commit> 类似于 checkout,会把文件恢复到 commit 的状态,但是新生成了一个 commit 到 HEAD;相当于生成了一个 commit 到 HEAD 之间反向的 diff 的 commit。

git cherry-pick <commits...> 是一个有趣的命令:它会提取指定的 commits diff 并**逐 commit **加到 HEAD 上。如同 merge 一样,它也会产生冲突,需要冲突解决。实践中,如果某些 commit 不想要了,或者只想从其他分支引用一些不冲突的 commit 进来,可以使用 cherry-pick。

冲突解决

对于 merge, rebase, cherry-pick 等命令,git 为了确保安全都设置了冲突检测逻辑。在发生冲突时,首先通过 git status 确定冲突文件,冲突文件会被修改成

未改动内容
<<<<<<< HEAD
HEAD 改动内容
=======
某 commit 改动内容
>>>>>>> 某 commit
未改动内容

手动选择要保留部分,git add <conflict-file> 然后 git <command> --continue 完成提交,即可解决。

如果放弃解决,可以用 --abort 还原到操作前,或是 --quit 保存现场但是清除错误记录。

个人想法

  • 历史是不应被随便篡改的,因此已有的 commit 不应该被交换、删除、合并;历史就是历史。当然会纯净主义者有另一套说辞,但无论如何,对于群体协作而言,篡改历史意味着不同人之间 git 历史的不同步和冲突。

  • 为了维护一个项目“主 branch”的独立和 git 树的简洁,当某个项目产生了破坏性的重构,如果已经无法与旧 branch 合并,那么就应该为之新建一个项目而非将它作为一个 branch。

  • 不要使用任何破坏性的指令。对于重组 commits,请使用 cherry-pick。对于删除某一个 commit 或回滚,请使用 revert。

  • 当然,当代码还没有 push 的时候,你本地做的那些新 commit 你爱怎么改就怎么改,反正都是你新加的东西,不会影响其他人的工作。