Git的所有操作都基于提交:你会暂存提交,创建提交,查看过去的提交记录,或者使用很多很多Git命令在不同的仓库之间转移提交内容。这些命令中的很大一部分都会以某种形式来操作提交,其中很多还会以提交ID作为参数。比如git checkout
命令,你可以传入一个提交ID用来查看那次的提交内容,或者传入一个分支名称用于切换分支。
如果更加理解提交的引用,你会让这些Git命令更加强大。在本文中,我们将会通过精雕细琢提交的引用,让其在git checkout
,git branch
或者git push
等普通命令的使用中大放光彩。
我们也会学习如何通过Git的reflog机制来找回那些看上去已经丢失的提交记录。
对于提交的最直接的引用方式是使用SHA-1哈希串。这个哈希串用来表示每一次提交的唯一ID。通过执行git log
命令的输出可以查找到每一次提交的哈希串。
commit 0c708fdec272bc4446c6cabea4f0022c2b616eba Author: Mary Johnson Date: Wed Jul 9 16:37:42 2014 -0500 Some commit message
想要将此哈希串作为参数传递给其他Git命令,只需要提供足够长度的字符串就可以了。比如说,你可以通过下面的命令来传入上面的哈希串,以便使用git show
命令查看那次提交的具体内容。
git show 0c708f
有些时候需要解析分支,tag,或者其他对于提交的间接引用。对于这种情况,你可以使用git rev-parse
命令。下面的命令返回指向main
分支的提交哈希串。
git rev-parse main
这对于编写只接受提交ID作为参数的自定义脚本非常有用。你可以使用git rev-parse
命令来标准化脚本的输入,进而进入对于提交的统一处理。这就避免了脚本用户需要手动解析提交的引用。
引用是一种对于提交的间接引用方式。你可以认为这是一种用户友好的哈希串别名。这其实也是Git表示分支和tag的内部机制。
引用的描述会以普通文件的形式存储在.git/refs
目录下。为了更加详细的了解引用描述,可以进入.git/refs
目录。你大概会看到一个类似下面结构的文件夹结构,当然由于你项目的仓库、分支、tag以及远程仓库不同,具体输出也会与下面不一样。
.git/refs/
heads/
main
some-feature
remotes/
origin/
main
tags/
v0.9
heads
文件夹中定义了你当前仓库中的所有本地分支。文件夹下的每一个文件名都与你本地的分支名对应,并且在每一个文件内你会找到一个表示提交的哈希串。这个哈希串表示这个分支的顶端提交ID。为了验证这个表示,可以在Git项目的根目录下执行下面的两行命令
# Output the contents of `refs/heads/main` file:
cat .git/refs/heads/main
# Inspect the commit at the tip of the `main` branch:
git log -1 main
cat
命令输出的提交哈希串应该与git log
命令输出的提交哈希串一致。
如果需要改变main
分支的引用,Git只需要修改refs/heads/main
文件的内容就可以了。类似的,创建一个新的分支也不过是创建一个新的文件,并在其中写入新的提交哈希串。这也是Git处理分支快于SVN的原因之一。
tags
目录的作用与此一致,只不过其中含有的是tag而不是分支。remotes
目录中是所有使用git remote
命令添加的远程仓库,每一个远程仓库都以一个独立的子目录代表。在每一个子目录中,你都会找到通过git fetch
命令下载到本地的对应远程仓库分支。
向Git命令传递引用时,即可以通过引用的全名进行引用,也可以使用简称进行引用,然后让Git根据简称去比对和搜索对应的引用。其实对于引用的简称你已经很熟悉了,因为这正是你使用分支名时实际发生的事情。
git show some-feature
实际上some-feature
这个参数其实是一个分支的简称。Git会将它解析为refs/heads/some-feature
然后再使用。当然你也可以直接使用分支的全名传递给Git命令:
git show refs/heads/some-feature
使用全名表示引用也可以避免歧义。有时候这还挺有必要的,比如说如果你同时给某个分支和tag都命名为some-feature
。这时候使用全名来表示引用就可以避免混淆分支和tag。
在关于refspecs的部分,我们还会看到更多关于ref的全名引用。
对于大型的仓库,Git会周期性的执行垃圾回收机制用来删除不必要的Git对象,以及把引用打包压缩到一个单独的文件,以便提高性能。你可以使用如下命令手动执行这个压缩动作:
git gc
此命令会把refs
文件夹下所有表示单独分支和tag的文件移动到.git
根目录下一个叫做packed-refs
文件中。如果打开文件查看,内容是像下面这样关于哈希串和ref的映射关系:
00f54250cf4e549fdfcafe2cf9a2c90bc3800285 refs/heads/feature
0e25143693cfe9d5c2e83944bbaf6d3c4505eb17 refs/heads/main
bb883e4c91c870b5fed88fd36696e752fb6cf8e6 refs/tags/v0.9
而对于外部来说,正常的Git功能不会受到影响。所以如果你想知道.git/refs
文件夹为什么变空了,这就是原因。
在refs
目录之外,还有一些用来表示特殊引用的文件放置在.git
目录的根目录下:
HEAD
– The currently checked-out commit/branch.HEAD
– 当前检出的提交/分支FETCH_HEAD
– The most recently fetched branch from a remote repo.FETCH_HEAD
– 最近一次检出的远程仓库分支ORIG_HEAD
– A backup reference to HEAD
before drastic changes to it.ORIG_HEAD
– 备份的HEAD
引用,以放置其发生剧烈变动MERGE_HEAD
– The commit(s) that you’re merging into the current branch with git merge
.MERGE_HEAD
– 使用git merge
命令合并到当前分支的其他分支引用CHERRY_PICK_HEAD
– The commit that you’re cherry-picking.CHERRY_PICK_HEAD
– 正在cherry-pick的提交这些引用都是由Git在必须时自行创建及更新。比如执行git pull
命令时先执行git fetch
,这一系列动作会更新FETCH_HEAD
引用。之后Git会自动执行git merge FETCH_HEAD
以便完成将远程分支合并到目标分支的操作。当然你可以像使用其他普通引用一样使用这些特殊引用,比如我相信你一定使用过HEAD
。
这些文件的内容依他们本身的类型和当前本地仓库状态而不同。HEAD
引用的内容可以是一个符号引用,所谓符号引用并不是一个提交哈希串,而不是一个对于其他引用的引用,或者其内容也可以就是一个提交哈希串。比如当你正在main
分支时查看下HEAD
引用的内容:
git checkout main
cat .git/HEAD
以上命令会输出ref: refs/heads/main
,就是说HEAD
指向了refs/heads/main
引用。通过这种方式Git就能知道当前被检出的分支就是main
分支。如果切换到其他分支,那么HEAD
引用的文件内容就会更新为那个新分支的引用。但是,如果你检出的不是一个分支,而是具体的一次提交,此时HEAD
引用内容就不是符号引用,而是一个具体的提交哈希串。通过这种方式Git就能知道当前处于游离的HEAD状态。
大多数情况下,HEAD
是唯一一个你会直接使用的引用。而其他的那些只会在编写操作Git内部机制的脚本时才会用到。
一个refspec用来表示一个本地分支和与之对应的远程仓库的分支之间的映射。利用这种映射关系我们可以使用本地Git命令来管理和操作远程分支,甚至也可以利用它们配置一些高级的git push
和git fetch
行为。
一个refspec通常表示为[+]``<src>``:``<dst>
的形式。
参数表示本地仓库的源分支,
参数则表示处于远程仓库中的目标分支。可选的加号符号是用来强制远程仓库执行不可快进的更新。
refspecs可以用于git push
命令,此时可以为远程分支指定一个分支名称。比如下面的命令会将本地的main
分支推送到名为origin
的远程仓库,此时与普通推送的行为是一致的。但是它却使用了qa-main
分支作为远程仓库的目标分支。对于QA团队想把最新的main
分支推送到他们专属的远程分支这一场景来说,这确实会比较方便。
git push origin main:refs/heads/qa-main
也可以使用refspec来删除远程分支。在功能分支工作流程中,向远程仓库推送功能分支是很常见的操作。但问题是当你在本地删除了某个功能分支之后(比如这个功能分支已经被合并),远程仓库中却还保留着这个功能分支。长此以往,当你的项目不断开发,远程仓库中会聚集起大量的死亡分支。这时候你可以使用空源分支的refspec,传递给git push
命令,用于删除远程分支。
git push origin :some-feature
注意Git v1.7.0以上的版本已经支持--delete
选项以替代上面的方法。使用如下所示:
git push origin --delete some-feature
通过对Git进行简单的配置,可以利用refspecs的特性改变git fetch
的默认行为。git fetch
操作默认拉取远程仓库的所有分支。底层原因在于.git/config
文件中的如下配置:
[remote "origin"]
url = https://[email protected]:mary/example-repo.git
fetch = +refs/heads/*:refs/remotes/origin/*
fetch
那一行的配置指定git fetch
从origin
远程仓库中下载所有分支。但是,一些工作流根本用不着所有分支。比如说,很多持续集成工作流只关心main
分支。为了仅fetch
main
分支,可以将fetch
那行的配置改成如下内容:
[remote "origin"]
url = https://[email protected]:mary/example-repo.git
fetch = +refs/heads/main:refs/remotes/origin/main
同样的,你也可以通过改变配置修改git push
的默认行为。比如如果你想要推送main
分支到origin
远程仓库时,总是将其推送到远程仓库的qa-main
分支(就像我们之前提到过的那样),你可以如下修改配置文件:
[remote "origin"]
url = https://[email protected]:mary/example-repo.git
fetch = +refs/heads/main:refs/remotes/origin/main
push = refs/heads/main:refs/heads/qa-main
利用refspec特性,可以让你完全控制Git命令如何在不同仓库之间转移分支。比如:
fetch
push
操作于不同名称的远程分支git fetch
git push
命令仅作用于指定的分支你可以对提交使用相对引用。~
用来表示与父提交节点的关系。比如下面的命令会输出HEAD
的祖父提交节点。
git show HEAD~2
然而,当相对引用涉及到合并提交时,事情会变得有些棘手。合并提交的父节点不止一个,也就是说如果希望上溯提交路径的话,路径也不止一个。在三路合并场景中,第一个父节点来自于你当时用于执行合并命令的分支,第二个父节点则是传入git merge
命令作为参数的那个分支。
使用~
字符上溯父节点关系时,Git总会去第一个父节点的路径向上寻找。如果你希望寻找的是第二个路径上的提交节点,需要通过^
字符来传入指定路径。如下例所示,如果HEAD
是一个合并提交,那么^2
则表示上溯第二个路径。
git show HEAD^2
你也可以使用多个^
字符来表示向上追溯多代合并。比如下面的命令会输出HEAD
(假设其为一次合并提交)的第二条路径上祖父提交节点。
git show HEAD^2^1
为了搞清楚~
和^
是如何工作的,下面的图示展示了如何从A
提交向上追溯不同的提交节点。对于有些情况,一个提交节点可以用多种不同的方式来表示。
Git命令可以像使用普通引用一样使用相对引用。如下例所示,所有命令都使用了相对引用
# Only list commits that are parent of the second parent of a merge commit
git log HEAD^2
# Remove the last 3 commits from the current branch
git reset HEAD~3
# Interactively rebase the last 3 commits on the current branch
git rebase -i HEAD~3
作为Git的保险装置,reflog记录了几乎所有你在仓库中的修改,无论修改操作是否提交了快照。你可以将其看做是你在本地仓库中所有操作的时序日志记录。通过执行git reflog
命令,可以查看reflog。输出的内容大致格式如下:
400e4b7 HEAD@{0}: checkout: moving from main to HEAD~2
0e25143 HEAD@{1}: commit (amend): Integrate some awesome feature into `main`
00f5425 HEAD@{2}: commit (merge): Merge branch ';feature';
ad8621a HEAD@{3}: commit: Finish the feature
这些内容大意如下:
HEAD~2
commit amend
操作feature
分支合并进main
分支HEAD{}
语法用于引用存储在reflog中的提交。它的功能与上一节介绍的HEAD~
用法类似,不同点在于HEAD{}
用于引用reflog中的记录而不是提交记录。
你可以利用这个特性来回退一些可能丢失的状态。比如说这样一个场景,当你使用git reset
命令废弃了某个功能的代码。此时你的reflog看上去大概想这个样子:
ad8621a HEAD@{0}: reset: moving to HEAD~3
298eb9f HEAD@{1}: commit: Some other commit message
bbe9012 HEAD@{2}: commit: Continue the feature
9cb79fa HEAD@{3}: commit: Start a new feature
在git reset
之前的那三次提交现在就处于悬空状态,意味着你无法引用他们——除了使用reflog。然后假设这时候你突然意识到不应该把这三次修改废弃掉。那么你只需要检出HEAD@{1}
这次提交,恢复到执行git reset
命令之前的状态即可。
git checkout HEAD@{1}
接下来你会处于游离HEAD
状态,这时候可以基于此创建一个新的分支,然后就回到正常工作流程中了。
现在你应该可以在Git仓库中如鱼得水般的引用提交了。我们学习到了分支和tag是如何存储在.git
的子目录中,也学习了如何阅读一个packed-refs
文件,以及HEAD
如何表示,如何利用refspec进行高阶的push
和fetch
操作,最后我们还学习了如何使用相对引用。
我们也大概介绍了一下reflog,这是一种可以引用其他途径无法引用的提交的方法。对于想要恢复那些看上去木已成舟的删除非常有用。
学习这些技能的目的是为了能够精准地在开发场景中找到希望找到的提交记录。并且这些知识点对于引入到已有的Git操作中也并非难事,毕竟常用的Git命令即可以接受引用的简称作为参数,当然也可以接受引用的全称。
原文地址:
Refs and the Reflog | Atlassian Git Tutorialwww.atlassian.com/git/tutorials/refs-and-the-reflog