0%

Git基础

Git 简易基础,供自己查看。

基本概念 (1)

仓库结构 (1.1)

1
2
3
4
5
6
7
8
9
10
11
12
13
project/
├── src/ # 工作区
└── .git/
├── HEAD # 当前位置:attached - 分支名;detached - commit id
├── config
├── refs/
│ ├── heads/
│ │ └── master # 本地分支 master 的指针
│ └── remotes/
│ └── origin/
│ └── master # 远程跟踪分支 origin/master 的指针
├── objects/ # 所有 commit、tree、blob 对象
└── index # 暂存区

本地仓库主要包含 2 部分:

  • git版本库(repository):.git 目录
  • 工作区(working directory):.git 目录之外的部分

主要涉及四个“空间”:

  • A. 工作区(在本地)
  • B. 本地分支(在本地)
  • C. 远程跟踪分支(在本地)
  • D. 远程分支(在远程,例如 github 上的分支)

注:这是简化模型,省略了暂存区(index)。下文 merge/rebase/reset 写回工作区时,通常也会同步更新 index。

远程仓库 remote (1.2)

远程仓库(remote)定义在 .git/config 的 remote 段中,例如:

1
2
3
[remote "origin"]
url = https://github.com/the-author/project.git
fetch = +refs/heads/*:refs/remotes/origin/*

其中:

  • remote 是指 github 上的名为 “project” 的仓库;
  • “origin” 是本地给这个远程仓库取的名字,方便引用;可以随意取名,只是习惯上叫 “origin”、”upstream” 等;
  • url 是这个 remote 的地址;

远程跟踪分支 (1.3)

远程跟踪分支在本地,用于跟踪远程仓库(例如名为 “origin” 的远程仓库)的分支。远程跟踪分支(本地的跟踪者)与远程分支(远程的被跟踪者)的对应关系定义在 remote 的 fetch refspec 中(后文把这个 fetch refspec 叫做 remote.<remote>.fetch,因为它就是这个配置项的的持久化):

1
fetch = +refs/heads/*:refs/remotes/origin/*

说明:虽然名叫 fetch refspec(remote.<remote>.fetch),但它的主要作用是定义远程跟踪分支和远程分支的对应关系,其实,push 也依赖这个定义。

这里有一个同名关系(星号通配符的作用):

github上远程仓库的分支 本地的远程跟踪分支
master origin/master
dev origin/dev
feature origin/feature
… origin/…

注意 refspec 两边不在同一个库里;它的格式是 +<src>:<dst>:

  • <src>(这里是 refs/heads/*)在远程仓库的命名空间里解析;fetch 时 git 先向远程仓库查询它有哪些 ref(见第1.7.4节),再用这个模式去匹配远程报来的列表;
  • <dst>(这里是 refs/remotes/origin/*)在本地库的命名空间里解析;
  • 开头的 + 表示强制更新,允许远程跟踪分支被 non-fast-forward 地覆盖,例如:上游——即 github 上的远程仓库——被 force push 过,fetch 还能成功,覆盖本地的远程跟踪分支(改写其历史);

一个容易产生迷糊的地方在于:你可以在本地 repository(.git 目录)中看到左半边 refs/heads/(即本地分支部分),所以,可能觉得 refspec 是本地分支与远程跟踪分支的对应关系。实际上,这是错误的,因为 refs/heads/ 这个路径在两个库(本地仓库和 github 上的远程仓库)里都存在,长得一模一样,而这里的 refspec 指的是远程仓库。也就是说,<src> 和 <dst> 是相对各自库的命名空间:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
github 上的 project 仓库                 本地仓库

project/ project/
├── src/ ├── src/
└── .git/ └── .git/
├── HEAD ├── HEAD
├── config ├── config
├── refs/ ├── refs/
│ └── heads/ <---- src │ ├── heads/
│ ├── master │ │ └── master
│ ├── dev │ └── remotes/
│ └── feature │ └── origin/ <---- dst
├── objects/ │ ├── master
└── index │ ├── dev
│ └── feature
├── objects/
└── index

远程仓库里并没有 refs/remotes/(因为它不需要跟踪你);只有你的仓库才有(因为你要跟踪远程仓库)。所以 refspec 两端不仅方向不同,命名空间也不重合。

可以这样实测(远程有 master、zzz;本地有 master、localonly):

1
2
3
4
5
6
7
# Case1: 左边写只在远程存在的 zzz —— 成功
git fetch origin refs/heads/zzz:refs/remotes/origin/tmp
# * [new branch] zzz -> origin/tmp

# Case2: 左边写只在本地存在的 localonly —— 失败
git fetch origin refs/heads/localonly:refs/remotes/origin/tmp2
# fatal: couldn't find remote ref refs/heads/localonly

Case2 的报错原文把这个名字称为 remote ref。man git-fetch 的措辞也是 “The remote ref that matches <src> is fetched”。

与 fetch 对应的是 push(注意,push 不是和 pull 对应,而是和 fetch 对应),fetch 与 push 的 refspec 是镜像的:

同样的 <src>:<dst> 语法,两端归属由命令方向决定:

命令 <src> 在哪个库 <dst> 在哪个库
git fetch origin <src>:<dst> 远程 本地
git push origin <src>:<dst> 本地 远程

规律:<src> 永远是数据的来源方,<dst> 永远是接收方;即 refs/heads/ 本身没有“本地/远程”的含义,是命令方向决定了它属于谁。

在 remote.<remote>.fetch 被配置的情况下(默认情况),fetch 和 push 都默认更新远程跟踪分支,不存在则新建(fetch 还更新 FETCH_HEAD)。但这不意味着远程跟踪分支是 fetch 和 push 的数据传输中介。假如不配置 remote.<remote>.fetch(使用 git config --unset remote.<remote>.fetch 删除),push 和 fetch 也能成功,但不更新远程跟踪分支,不存在也不新建(fetch 更新 FETCH_HEAD,若指定了本地分支 <dst>,还更新它)。

本地分支的上游——另一套映射 (1.4)

第1.3节是说远程分支与远程跟踪分支之间的对应。还有另一套对应:本地分支与远程分支,后者称为前者的上游。它不是由上面的 fetch refspec 定义的,而是写在 .git/config 的 branch 段里:

1
2
3
[branch "master"]
remote = origin
merge = refs/heads/master

注意,这两行指认的是远程仓库的一个分支,即一个远程分支:

  • remote: 远程仓库名(这里是 “origin”)
  • merge: 远程仓库的分支(这里是 master),refs/heads/master 在远程仓库的命名空间里解析;

在 git clone 时,会自动为默认分支写上这段。

还有一些其它方式设置上游:

  • 创建分支时设置上游:

    • 命令:git switch -c bugfix origin/bugfix
    • 解释:origin/bugfix 是新分支的起点;起点是远程跟踪分支时会自动设置上游(branch.autoSetupMerge 默认开启;--no-track 选项可以禁止这一行为);
    • 注意:新分支起点 {remote}/{branch} 是一个远程跟踪分支名(而不是远程分支名),git 设置的上游是远程分支,它会使用“远程跟踪分支与远程分支”的对应关系,做一次换算。
  • 为当前分支设置上游:

    • 命令:git branch -u origin/bugfix
    • 解释:origin/bugfix 是 -u 的参数,全称是 --set-upstream-to=origin/bugfix;
    • 注意:上游参数形式是 {remote}/{branch},它也是一个远程跟踪分支名(而不是远程分支名),git 同样会使用“远程跟踪分支与远程分支”的对应关系,做一次换算。
  • 推送时设置上游:

    • 命令:git push -u <remote> <src>[:<dst>]
    • 解释:这里 -u 是选项,没有参数,全称是 --set-upstream,表示把 <src> 的上游设置成远程仓库 <remote> 的 <dst> 分支;
    • 注意1:与上面两个命令不同,这里不需要换算——<remote> 和 <dst> 已经分别给出了远程仓库名和远程分支名;
    • 注意2:-u 只在 <src> 是本地分支或 attached HEAD 时起作用;若用 tag、commit id 或 detached HEAD 做 <src>,-u 被静默忽略(另外,commit id 和 detached HEAD 做 <src> 时不能省略 <dst>,必须写成 refs/heads/... 全名,否则 push 本身失败,加不加 -u 都一样);
    • 注意3:若 <dst> 缺省,则为与 <src> 同名的远程分支或 tag(<src> 为分支、attached HEAD 或 tag)——假设 remote.<remote>.push 没被设置(默认)或是被设置但没有匹配 <src> 的项,否则 <dst> 由配置项决定。

commit id (1.5)

每个 commit 都有一个唯一的 id,例如:

1
a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0

它本质上是一个 SHA-1 哈希值,由 git 根据 commit 的内容计算得出,所以也叫 commit hash;是对哪些信息的哈希呢?

  • tree:本次提交对应的目录树快照;
  • parents:父 commits(可能有多个)的 id;
  • author:作者、时间;
  • committer:提交者、时间;
  • commit message:提交信息。

只要其中任何一项发生变化,生成的 commit id 就会不同。这也是为什么 rebase 会改变 commit id:其实 rebase 是创建了新的 commit 对象,新 commit 和 原 commit 的 parent 不同,hash 自然不同。

完整 id 有 40 个十六进制字符,日常使用时 git 允许使用前缀缩写,只要不产生歧义即可。例如:

1
2
git show a1b2c3d
git log --oneline # 默认显示 7 位缩写

git log --oneline 通常显示 7 位,大型仓库可能需要更多位才能唯一标识。可以用 git rev-parse --short HEAD 查看当前仓库的默认缩写长度。

为什么 commit id 很重要?

  • 唯一标识:每个 commit id 全局唯一,可以精确指向某个提交。
  • 不可篡改:commit id 由内容哈希决定,修改任意内容都会产生新的 id(例如 rebase、amend),旧 id 对应的对象仍然保持不变。
  • 引用基础:分支、tag、HEAD 本质上都是指向某个 commit id 的指针。
  • DAG 的边:commit 通过 parent 字段存储父 commit 的 id(即 parent 关系,见第1.6节),从而构成有向无环图。

查看 commit id:

1
2
3
4
5
6
7
8
9
10
11
# 查看当前 HEAD 的完整 id
git rev-parse HEAD

# 查看某个分支的完整 id
git rev-parse master

# 查看简短 id
git rev-parse --short HEAD

# 在 log 中查看
git log --oneline

因为 commit id 由内容决定,所以commit 对象一旦创建就无法被修改:如果修改了提交信息、作者或文件内容,git 会创建一个全新的 commit 对象,拥有新的 id。即不能针对某个 commit id,修改它的信息,因为信息一改 commit id 也变了。这就是 git 说 commit 不可变的底层原因——不是不能改变历史,而是改变历史的方式是创建新对象,而不是修改旧对象(见第1.6节)。

parent 关系 (1.6)

除了 root commit 之外,每个 commit 都有 parent;

  • 普通的 commit 有一个 parent;
  • merge commit 有多个 parent:在当前分支上执行 merge $\text{branch}_1$ $\text{branch}_2$ $\cdots$ $\text{branch}_n$ 时,产生的 merge commit 通常有 $n+1$ 个 parent——包括当前分支自己的 merge 前的最新 commit。

可以通过命令查看 parent:

1
git cat-file -p {commit-id}

通过 parent 关系,所有的 commit 构成一张有向无环图(DAG)。一经提交,一个 commit 就作为一个顶点,连到图中了。已经存在的 commit 对象无法改变,但图的结构可以通过改变,例如 rebase:

  • 把需要移动的 commit 拷贝到新位置;
  • 移动分支指针,旧 commit 不再被任何分支引用;
  • 若旧 commit 不再被任何分支、tag、HEAD 或 reflog 引用,它可能被 git gc 物理地删除(reflog 引用会自动过期,只是短期可以查);

一方面,commit 没有变(git 强调 commit 不可变);另一方面,对最终用户而言,创建新 commit 并回收旧 commit 修改了图的结构——把一个顶点移动到别的位置。这里“移动一个顶点的位置”,本质上是在旧地方删除一个旧 commit,然后在新地方插入一个新 commit;新 commit 与旧 commit 的 parent 不同,自然 hash(commit id)也不同。

除了 rebase 之外,还有 amend(git commit --amend 只作用于 HEAD 当前指向的那个 commit;原地新建一个 commit,由于内容变了,commit id 自然也变)、filter-branch 等少量改写历史的操作。不考虑它们的话,commits 通过 parent 关系构成的 DAG 是固定的;新的提交只会在原有图上添加节点和边,不会修改已有的 commit 对象(不修改已有部分)。

这张图才是 git 的根基。学习分支、HEAD 等概念时,要始终记得:分支可以指向图中的任意 commit;HEAD 可以是 attached,即引用某个分支,也可以是 detached,即直接存储一个 commit id。但无论指针如何移动,已有的 commit 对象本身不会改变;从某个 commit 沿 parent 回溯得到的历史(git log)也不会改变——除非使用 rebase、amend、filter-branch 等命令改写了历史。

ref (1.7)

定义 (1.7.1)

ref = 一个以 refs/ 开头的名字 + 一个值。值有两种可能:

  • 直接 ref:值是一个对象(commit 或附注 tag)的 id;
  • 符号 ref:即 symbolic ref,值是另一个 ref 的名字;

存储:

  • loose:.git/refs/ 下的一个文件,名是文件名(去掉 .git/),值是文件内容;
  • packed:.git/packed-refs 里的一行,名是第二列,值是第一列。注:对于附注 tag,第一列不是 tag 对象的id,而是最终的 commit id(见下文);
1
2
3
4
5
6
7
8
9
cat .git/refs/heads/master
# 26aa42f5bf53ae6ee7646eb9d3d03ea975da566c

git pack-refs --all # 打包之后 .git/refs/heads/ 变成空目录
cat .git/packed-refs
# # pack-refs with: peeled fully-peeled sorted
# b74b535798729c9498fa8c96f1bcaec43e687e34 refs/heads/dev
# 26aa42f5bf53ae6ee7646eb9d3d03ea975da566c refs/heads/master
# ...

所以,不要靠 cat 文件判断 ref 的值;用 git rev-parse <ref> 或 git for-each-ref 最可靠:

1
2
3
4
5
6
7
8
git for-each-ref --format='%(refname) %(objecttype) %(objectname:short)'
# refs/heads/dev commit b74b535
# refs/heads/master commit 26aa42f
# refs/remotes/origin/HEAD commit 26aa42f
# refs/remotes/origin/dev commit b74b535
# refs/remotes/origin/master commit 26aa42f
# refs/tags/ann tag 0947e8d
# refs/tags/light commit 26aa42f

可以看到,本地分支、远程跟踪分支、tag 都是 ref,是同一种东西;区别只在于放在哪个命名空间下,以及 git 对这个命名空间的规则:

命名空间 叫法 怎么移动
refs/heads/ 本地分支 在它上面提交时自动前移;reset 可以任意移动
refs/remotes/<remote>/ 远程跟踪分支 只在与远程通信(fetch/push)时更新
refs/tags/ tag 一般不移动

此外还有 refs/stash(git stash 用)、refs/notes/ 等。

如前所述,ref 的值是对象 id,对象不一定都是 commit。看 tag 的两种类型:

  • 附注 tag(git tag -a):

    • 会创建一个 tag 对象,且这个 tag 对象有自己的 id(例如 91f24a3…);tag 对象的内容是:它指向的对象(通常是 commit)的 id、它指向的对象(通常是 commit)的类型(commit 的类型就是 commit)、tag 自己的名字、打 tag 的人和时间、说明文字;
    • 值(refs/tags/v2.8 文件的内容)是这个 tag 对象的 id(例如 91f24a3…),不是 commit id;
    • 也是直接 ref,虽然它间接引用一个 commit,但它的值是 tag 对象的 id,而不是另一个 ref;
  • 轻量 tag(git tag):

    • 没有 tag 对象;
    • 值(refs/tags/v2.8 文件的内容)直接就是 commit id;
    • 形式和分支一样,只是放在 refs/tags/ 下;
    • git for-each-ref 命令也不会将它作为 tag 类型输出,而是作为 commit 类型输出;
    • 所以,轻量 tag 本身就是 commit ref(值是 commit id);
    • 可以理解为:不会移动的分支!;

前面说,“直接 ref 的值是一个对象(commit 或附注 tag)的 id”。轻量 tag 属于 “commit 或附注 tag” 中前一种 case,即值是一个对象 commit 的 id;

可以使用 git cat-file -t 查看对象(commit 或附注 tag)的类型:

1
2
3
4
git cat-file -t refs/tags/light       # commit   (轻量 tag 是 commit 类型)
git cat-file -t refs/tags/ann # tag (附注 tag 的 tag 对象 的类型)
git rev-parse --short ann # 0947e8d (tag 对象)
git rev-parse --short 'ann^{commit}' # 26aa42f (它最终指向的 commit)

直接 ref 与符号 ref (1.7.2)

形态 值 例子
直接 ref 一个对象 id detached HEAD、refs/heads/master、refs/remotes/origin/master
符号 ref ref: <另一个ref> attached HEAD、ref: refs/heads/master、refs/remotes/origin/HEAD

可见:

  • 分支通常就是一种直接 ref(也可以是符号 ref,相当于分支别名,但很少见,不考虑);
  • 远程跟踪分支通常也是直接 ref(refs/remotes/origin/HEAD 除外,它是符号 ref:它记录的是远程仓库的默认分支,也就是远程 HEAD 指向的那个分支;git clone 会自动建好它,所以平时可以把 origin/master 简写成 origin;origin 直接对应的是 refs/remotes/origin/HEAD,这正是第 1.7.5 节简写规则里 refs/remotes/<name>/HEAD 那一条);
  • HEAD 虽然不再 refs/ 目录下,也是ref(无论是 attached 还是 detached);

命令 git symbolic-ref 相当于间接引用的 dref 操作(符号 ref 就像二级指针,它的值是另一个 ref 的名字,即一级指针):

1
2
3
4
5
6
7
cat .git/HEAD                              # ref: refs/heads/master
git symbolic-ref HEAD # refs/heads/master
git symbolic-ref refs/remotes/origin/HEAD # refs/remotes/origin/master

git switch --detach HEAD
cat .git/HEAD # 26aa42f5bf53ae6ee7646eb9d3d03ea975da566c
git symbolic-ref HEAD # fatal: ref HEAD is not a symbolic ref

符号 ref 被使用时会递归解引用,直到得到一个对象 id:HEAD -> refs/heads/master -> 26aa42f。

HEAD 与 pseudoref (1.7.3)

如上所述,HEAD 是不在 refs/ 目录中的 ref;类似的还有:

  • ORIG_HEAD:reset/merge/rebase 等操作之前 HEAD 的位置;
  • FETCH_HEAD:上一次 fetch 取回了什么;
  • MERGE_HEAD:merge 冲突未解决时,被合并进来的那个 commit。

除 HEAD 外,这些叫 pseudoref;HEAD 与 pseudoref 可以像 ref 一样在命令里使用(如 git reset --hard ORIG_HEAD),但 git for-each-ref 不列出它们。

本地 ref 与远程 ref (1.7.4)

每个仓库有各自的 ref。前几节主要在说本地仓库的 ref;远程仓库也有它自己的一套 ref,称为 remote ref(第1.3节 fetch 报错 couldn't find remote ref ... 里的就是这个词)。git ls-remote 可以列出远程仓库的 ref(这个命令需要联网才能执行):

1
2
3
4
5
6
7
git ls-remote origin
# 26aa42f5bf53ae6ee7646eb9d3d03ea975da566c HEAD
# b74b535798729c9498fa8c96f1bcaec43e687e34 refs/heads/dev
# 26aa42f5bf53ae6ee7646eb9d3d03ea975da566c refs/heads/master
# 0947e8debef2f67acb4243d776b82c4d949b3b04 refs/tags/ann
# 26aa42f5bf53ae6ee7646eb9d3d03ea975da566c refs/tags/ann^{}
# 26aa42f5bf53ae6ee7646eb9d3d03ea975da566c refs/tags/light

第1.3节说的“fetch 时 git 先向远程仓库查询它有哪些 ref”,返回的就是这样一张列表,fetch refspec 的 <src> 就拿来匹配这张表。

因此,同样写作 refs/heads/master,在本地仓库指本地分支,在远程仓库指远程分支;而远程跟踪分支 refs/remotes/origin/master 是本地的一个 ref,内容是上次 fetch 时远程 ref refs/heads/master 的值的拷贝。

经过同步,例如本地 refs/heads/dev 被 push 到远程 refs/heads/master(命令 git push origin dev:master 要 fast-forward)之后,本地分支 refs/heads/dev、对应远程跟踪分支 origin/master(条件是 origin 是远程名,不是 URL,且 remote.origin.fetch 把 refs/heads/master 映射为 refs/remotes/origin/master)和远程分支 refs/heads/master 的值都相同,是同一个 commit id。前面说过 commit id 是由 commit 对象的内容(tree、parent、作者、提交者、时间、说明)算出来的哈希值,push 传的是这个对象本身,原样拷过去,不会在对方那边重新生成。

反过来,git fetch origin master(假设 remote.origin.fetch 带 +,可以 non-fast-forward 地更新远程跟踪分支)之后,本地远程跟踪分支 refs/remotes/origin/master 和远程 refs/heads/master 的值也相同;或者直接 git fetch origin +master:dev(若不带 + 且不满足 fast-forward 条件,则不更新本地分支 refs/heads/dev)取到本地 dev 分支之后,本地分支 refs/heads/dev、对应远程跟踪分支 origin/master 和远程分支 refs/heads/master 的值都相同。注:git fetch origin +master:dev 的 dev 不能是当前分支(检出分支),报错 “fatal: refusing to fetch into branch ‘refs/heads/dev’ checked out at ‘‘“。

ref 的简写 (1.7.5)

命令行里写的 master、origin/dev 都是简写,git 按固定顺序补全成全名(man gitrevisions):

  1. .git/<name>(HEAD、ORIG_HEAD 等)
  2. refs/<name>
  3. refs/tags/<name>
  4. refs/heads/<name>
  5. refs/remotes/<name>
  6. refs/remotes/<name>/HEAD
1
2
3
4
5
git rev-parse --symbolic-full-name HEAD       # refs/heads/master(HEAD 被解引用)
git rev-parse --symbolic-full-name master # refs/heads/master
git rev-parse --symbolic-full-name light # refs/tags/light
git rev-parse --symbolic-full-name origin/dev # refs/remotes/origin/dev
git rev-parse --symbolic-full-name origin # refs/remotes/origin/master(第6条)

tag 排在本地分支前面。若 tag 与分支同名,git 会警告并优先取 tag:

1
2
3
4
git tag dev             # 再建一个与分支 dev 同名的 tag(指向 master 的 26aa42f)
git rev-parse --short dev
# warning: refname 'dev' is ambiguous.
# 26aa42f # 取的是 tag,不是分支 dev 的 b74b535

所以第1.4节注意2提到的“写成 refs/heads/... 全名”,就是绕开这套猜测,直接给出全名。

不是 ref 的东西:commit id 与 revision 表达式 (1.7.6)

很多命令的参数写作 <commit>,既可以写 ref 也可以写 commit id、HEAD~1、master^、master@{1} 等,这些叫 revision(包括 commit id),只是“指定一个 commit 的写法”,不是 ref:没有名字被存储下来,用完就弃,也不会移动。

1
2
3
4
5
git rev-parse --symbolic-full-name HEAD~1       # (空)
git rev-parse --symbolic-full-name 'master@{1}' # (空)
git rev-parse --symbolic-full-name 26aa42f # (空)
git rev-parse --short HEAD~1 # 3a7fdb1 (但能解析出 commit)
git show-ref HEAD~1; echo $? # 1 (show-ref 只认 ref)

refspec (1.8)

定义:A “refspec” is used by fetch and push to describe the mapping between remote ref and local ref.

格式是 [+]<src>:<dst>。文中各处的 refspec 都是这个东西,出现在四个位置。

前两个是配置:

  • 配置 remote.<remote>.fetch:

    • 持久化在 .git/config 的 remote 段中:fetch = [+]<src>:<dst>;默认存在;
    • 获取:git config --get-all remote.<remote>.fetch,或者直接读取 .git/config 的对应 remote 段;
    • 设置:git config --add remote.<remote>.fetch [+]<src>:<dst>,或者直接编辑 .git/config 的对应 remote 段;
    • 结构:可以多行,即多个 <src>:<dst> pair;相当于一个映射表(map);不过,一般使用通配符,如第1.3节 +refs/heads/*:refs/remotes/origin/*;
    • 作用:主要是定义远程分支和远程跟踪分支的关系,fetch 和 push 都依赖它(配置项名和持久化字段名都非常 misleading)!还有一个作用:不带 refspec 时,它决定 fetch 取哪些分支(被 remote.<remote>.fetch 配置,或符合通配符的所有远程分支)!
  • 配置 remote.<remote>.push:

    • 持久化在 .git/config 的 remote 段中:push = [+]<src>:<dst>;默认不存在;
    • 获取:git config --get-all remote.<remote>.push,或者直接读取 .git/config 的对应 remote 段;
    • 设置:git config --add remote.<remote>.push [+]<src>:<dst>,或者直接编辑 .git/config 的对应 remote 段;
    • 结构:可以多行,即多个 <src>:<dst> pair;相当于一个映射表(map);也支持通配符;
    • 作用:这里可以顾名思义:push 命令不带 refspec 时,此配置决定推什么(被 remote.<remote>.push 配置,或符合通配符的所有本地分支;远程没有则创建);push 命令带缺省 <dst> 的 refspec 时,若此配置中有匹配项,则用它决定 <dst>;若无匹配,则 <dst> 为与 <src> 同名的远程分支或 tag(<src> 为分支、attached HEAD 或 tag);

默认情况下,remote.<remote>.push 没有设置!

另外两个是 fetch 和 push 命令(注意,下面说 fetch 和 push 都更新了远程跟踪分支,其前提是 remote.<remote>.fetch 被定义,能够找到远程跟踪分支和远程分支的对应关系,因为如第1.3节所说,远程跟踪分支并不是 fetch 和 push 的数据传输中介。然而默认情况下,remote.<remote>.fetch 总是被定义,所以,我们总能看到远程跟踪分支被 fetch 和 push 更新):

  • fetch 命令:

    • 更新:FETCH_HEAD、远程跟踪分支(若 <src> 是远程分支而不是 tag)、本地分支 <dst>(若 <dst> 没缺省,否则不更新本地分支);
    • git fetch:从当前分支上游所在的远程(没有上游就用 origin)取回所有分支(即被 remote.<remote>.fetch 配置,或符合通配符的所有远程分支),更新该远程对应的所有远程跟踪分支(对应远程分支被删了的除外);指向已取回历史的 tag 也会顺带取回;不更新任何本地分支!
    • git fetch <remote>:同上,只是 <remote> 已确定;
    • git fetch <remote> <src>:只取 <src>,更新 FETCH_HEAD、对应的远程跟踪分支,不更新本地分支;
    • git fetch <remote> [+]<src>:<dst>:只取 <src>,更新 FETCH_HEAD、对应的远程跟踪分支,以及本地分支 <dst>;
    • + 表示允许 <dst> 被 non-fast-forward 地更新(当然是 <dst> 没缺省的情况下),不带则要求 fast-forward 地更新;
    • 注意1:命令行中的 + 和 remote.<remote>.fetch 配置项(remote 段的 fetch refspec)+<src>:<dst> 中的 + 不同,后者表示允许 non-fast-forward 地更新远程跟踪分支;一条 git fetch origin +<src>:<dst> 会同时用到这两条 refspec:命令行中的 + 决定 <dst> 能被 non-fast-forward 地更新,而配置中的 + 决定远程跟踪分支能被 non-fast-forward 地更新!
    • 注意2:<dst> 不能是当前分支,即 attached HEAD 指向的分支,也叫检出分支,报错 “fatal: refusing to fetch into branch ‘refs/heads/‘ checked out at ‘‘“(补充:rebase 进行到一半时 HEAD 是 detached,但 git 仍把该分支当作已检出,fetch 写入它会被拒)!
  • push 命令:

    • 更新:远程分支(没有则创建)、远程跟踪分支(若 <dst> 是分支而不是 tag,且 <remote> 写的是远程名而不是 URL;没有则创建);
    • git push:若 remote.<remote>.push 被配置,见上文。否则(默认大多数情况),推送当前分支到它的上游(假设 push.default=simple),没有上游或上游不同名时拒绝;成功后更新:远程分支、对应的远程跟踪分支;
    • git push <remote> <src>:若 remote.<remote>.push 被设置且有匹配 <src> 的项,则从配置获取 <dst>,推送到它,并且更新对应的远程跟踪分支,与上游无关(即远程 <dst> 不一定是本地 <src> 的上游)。否则(默认大多数情况),推送本地 <src> 到远程同名分支或 tag(<src> 为分支、attached HEAD 或 tag);更新:远程 <src>(远程 <src> 是分支或 tag)、对应的远程跟踪分支(若远程 <src> 是分支)。同样,这也与上游无关(即远程 <src> 不一定是本地 <src> 的上游)。
    • git push <remote> [+]<src>:<dst>:推送本地 <src> 到远程 <dst>;更新:远程 <dst>(分支或 tag)、对应的远程跟踪分支(若远程 <dst> 是分支);
    • + 表示允许 non-fast-forward 地更新远程分支;
    • 注意1:只要 push 成功,对应的远程跟踪分支就会被更新,不管是 fast-forward 还是 non-fast-forward. 所以命令行中的 + 只决定远程分支能被 non-fast-forward 地更新!
    • 注意2:和 fetch 命令不同:fetch 缺省 <dst> 则不更新它!而 push 缺省 <dst> ,则根据上述规则推导 <dst>!

分支 (1.9)

本质上,分支就是 refs/heads/ 下的一个引用(ref):

  • 通常存放为 .git/refs/heads/ 里的一个文件(内容是一个 commit id);
  • 但也可能被打包进 .git/packed-refs,此时上述文件不存在;
  • 所以,用 git rev-parse {分支} 查看最可靠;

它不会自动记录“整个分支链”,即不会记住 DAG 图(见第1.6节)中的某条路径;它只记住:当前分支顶端是哪一个 commit id。所以,分支就是一个标签(分支名)加一个指向某个 commit 的指针(指针指向某个 commit 或引用某个 commit,意思就是记录那个 commit 的 id)。

1
2
cat  .git/refs/heads/dev
bb597f76b0c7e84541c0a180578cec519ad4a0da # dev 分支顶端 commit id

这里所说的顶端:通过 reset 回到过去某个 commit,那个 commit 就成了顶端。

假如,dev 和 master 分支经过开发、合并、再开发,演进到如下状态:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
                                 HEAD 
|
V
master
|
|
+---[e]----[g] |
/ | |
/ | V
[a]--[b]--[c] +-[M]--[i]
\ /
\ /
[d]----[f]------[h]
^
|
dev

此时:

1
2
3
.git/refs/heads/master -> [i] 的 commit id
.git/refs/heads/dev -> [h] 的 commit id
HEAD -> master

通常,分支指向它演进路上的最新的 commit,但也可以通过 reset 回到过去。

两个分支可以同时指向一个 commit,这很常见,例如:

1
2
git switch feature
git branch some-feature

此时 feature 和 some-feature 就指向同一个 commit;之后只要其中一个分支有新提交,指针就会分开。

Git 里分支的底层实现就是一个指向 commit 的指针,而不是一个文件夹、一条链表或一棵树。既然分支只是一个commit id,根本不是“树枝”一样的东西,它为什么叫 branch?

因为,从使用者的视角,分支是一条独立的开发线,可以并行改代码、再合并。可以这样比喻:

  • 把 commits 想成时间线上的存档点:a, b, c, d, e, f, …
  • 分支就是贴在这个时间线上的“标签”,如上图:
    • 刚创建 dev 时,[c] 上同时贴着 “master” 和 “dev”;
    • 之后 dev 提交到 [d],”dev” 标签移到 [d];
    • master 提交到 [e],”master” 标签移到 [e];
    • dev 继续提交到 [f]、[h],”dev” 标签先移到 [f] 最终移到 [h];
    • master 提交到 [g],”master” 标签移到 [g];
    • master 合并后经过 [M],最终指向 [i],”master” 标签经 [M],最终移到 [i];
  • 这样,就形成了 dev 分支和 master 分支;
  • 注意1:
    • master 不是 [a], [b], [c], [e], [g], [M], [i] 这一整串;dev 也不是 [c], [d], [f], [h] 这一整串;它们各自只是一个 commit id;
    • 分支标签同一时刻只贴在一个 commit 上;提交后标签会移动,所以之前贴过的 commit 不再被该分支“指向”,但仍属于它的历史;
    • 没贴过某个标签的 commit 也能成为对应分支的历史:[d], [f] 从未贴过 “master” 标签,但属于 master 的历史(因为 [M] 有两个父 commit,一个来自 master 方向,另一个来自 dev 方向);
    • 所以,不是贴过某个标签的 commits 构成的序列,就是那个分支;
    • 分支也不对应第1.6节中说的 DAG 中的某条路径;

实际上,分支就是 DAG 上的一个指针:完全可以通过 reset 让分支指向任何 commit 上,并从那里开始演进。并且,reset 到某个地方之后,看到的历史就是从那个地方按 parent 关系往前回溯。

例如上图状态下,在 dev 分支上执行 git reset --hard [e]的commit-d 之后,就变成:

1
2
3
4
5
6
7
8
9
10
11
12
13
                  HEAD           HEAD 
| |
V V
dev master
| |
V |
+---[e]----[g] |
/ | |
/ | V
[a]--[b]--[c] +-[M]--[i]
\ /
\ /
[d]----[f]------[h]

此时,[d], [f], [h] 不再属于 dev 分支的历史,git log 只会看到 [a], [b], [c], [e].

HEAD (1.10)

HEAD 就是 .git/HEAD 这个文件,它记录“我当前在哪里”。它有两种形态:

  • attached(正常状态):里面存的是一个分支名,即符号引用(symbolic ref);
1
2
cat .git/HEAD
ref: refs/heads/master

间接:HEAD -> 分支名 -> commit id。

  • detached(游离状态):里面直接存一个 commit id,不经过任何分支;
1
2
cat .git/HEAD
d50b5fb40bc328e920210403c940c548c8a83c8f

直接:HEAD -> commit id。

HEAD 有两个作用:

  1. 提交时的父 commit:新 commit 的 parent 就是 HEAD 当前所指的 commit。
    • merge 提交时,HEAD 是第一个 parent;
    • --amend 提交时,新提交沿用的是 HEAD 的 parent,而不是 HEAD 本身;
  2. 决定提交后移动谁:
    • attached 状态:提交会让 HEAD 所指的那个分支指向新 commit;HEAD 本身的内容不变,仍是 ref: refs/heads/xxx;
    • detached 状态:移动的是 HEAD 自己,即修改 HEAD 里存的 commit id;

attached 状态的提交:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
                                master                                      master     
| |
| |
+---[e]----[g] | +---[e]----[g] |
/ | | / | |
/ | V / | V
[a]--[b]--[c] +-[M]--[i] ==> [a]--[b]--[c] +-[M]--[i]
\ / \ /
\ / \ /
[d]----[f]------[h] [d]----[f]------[h]--[j]
^ /
| /
dev dev refs/heads/dev 变化
^ ^
| |
HEAD HEAD 仍是 refs/heads/dev

detached 状态的提交:

1
2
3
4
5
6
7
8
9
10
11
12
13
                                master                                      master     
| |
| |
+---[e]----[g] | +---[e]----[g] |
/ | | / | |
/ | V / | V
[a]--[b]--[c] +-[M]--[i] ==> [a]--[b]--[c] +-[M]--[i]
\ / \ /
\ / \ /
[d]----[f]------[h] [d]----[f]------[h]--[j]
^ /
| /
HEAD HEAD 变化

同理,git reset attached 状态下移动的是当前分支;detached 状态下移动的是 HEAD。

attached 状态的 reset,见第1.9节末尾;

detached 状态的 reset:

1
2
3
4
5
6
7
8
9
10
11
12
13
                                master                        HEAD          master      
| | |
| V |
+---[e]----[g] | +---[e]----[g] |
/ | | / | |
/ | V / | V
[a]--[b]--[c] +-[M]--[i] ==> [a]--[b]--[c] +-[M]--[i]
\ / \ /
\ / \ /
[d]----[f]------[h] [d]----[f]------[h]
^
|
HEAD

查看 HEAD

1
2
3
4
5
cat .git/HEAD              # 看原始内容,能直接区分两种形态
git symbolic-ref HEAD # 打印 refs/heads/master;detached 时报错 "ref HEAD is not a symbolic ref"
git branch --show-current # 打印 master;detached 时输出为空
git rev-parse HEAD # 两种形态都打印最终的 commit id(会自动解引用)
git status -sb # 正常时显示 ## master...;detached 时显示 ## HEAD (no branch)

注意 git rev-parse HEAD 总能拿到 commit id,所以不能用它判断是否 detached;要判断就用上面前三条。

基于 HEAD 的 commit 坐标写法

写法 含义
HEAD 当前 commit
HEAD^0 等于 HEAD
HEAD^ 既等于 HEAD~1,又等于 HEAD^1
HEAD~1 往上 1 代
HEAD~2 往上 2 代 (merge commit 有多个父,沿第一个往上,不会拐到被合并的分支上)
HEAD~n 往上 n 代 (merge commit 有多个父,沿第一个往上,不会拐到被合并的分支上)
HEAD n 个 ^ 等于 HEAD~n
HEAD^1 第 1 个父 commit(只有 merge commit 才有多个父,第 1 个父是主动合并方)
HEAD^2 第 2 个父 commit(只有 merge commit 才有多个父,第 2 个父是被合并方)
HEAD^m 第 m 个父 commit(只有 merge commit 才有多个父,m > 1 是被合并方)
HEAD@{2} reflog 里 HEAD 两次变动之前的位置(git reflog 可查看)
ORIG_HEAD 上一次 reset/merge 等危险操作之前 HEAD 的位置,用于反悔

以下图为例:HEAD^1 是 [g](master 方向),HEAD^2 是 [f](dev 方向),HEAD~2 是 [e](继续沿第一父)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
                                 HEAD 
|
V
master
|
+---[e]----[g] |
/ | |
/ | V
[a]--[b]--[c] +------[M]
\ / (merge commit)
\ /
[d]----[f]
^
|
dev

如何进入、退出 detached 状态:

1
2
3
4
git switch --detach <commit>   # 进入 detached 状态:推荐写法
git checkout <commit> # 进入 detached 状态:老写法,效果相同
git switch master # 退出 detached 状态:直接切到某个分支,推荐写法
git switch - # 退出 detached 状态:回到上一个分支,例如,HEAD 恢复为 ref: refs/heads/xxx(只有上一个位置是分支时才能回去)

此外,查看历史提交的内容、二分查找(git bisect)、rebase 进行到一半时也会处于 detached 状态。

detached 状态的风险:

风险在于此时的提交没有任何分支指向它:

1
2
3
4
git branch -v
* (HEAD detached from d50b5fb) 3089de8 orphan-commit # 新 commit 只被 HEAD 指着
dev a475d8f d1
master 2e584d2 merge

一旦切走,HEAD 改指别处,这个 commit 就不再被任何引用指向,git log 看不到,久置(从切走时算起大约 30 天)会被 gc 清理(短期内还能用 git reflog 找回)。所以,在 detached 状态下做了提交又想保留,切走之前要先给它一个分支:

1
git switch -c new-branch       # 把当前 HEAD 所指的 commit 固定到一个分支上

若直接切走(不创建分支),则会有 Warning: you are leaving 1 commit behind, not connected to any of your branches. 此时可以通过 git branch <new-branch-name> <orphan-commit-id> 补救。

常见基本操作 (2)

fetch (2.1)

git fetch {remote-name} {remote-branch},例如 git fetch origin master

  • 从名为 “origin” 的那个 remote 库(github 上的 “project” 库)的 master 分支下载最新提交;
  • 更新本地对应的远程跟踪分支(对应关系见前文,本例中就是 origin/master);
  • 不会合并到当前本地分支;
  • 也不会修改工作区;
  • 即:D -> C;(见第1.1节的四个空间)

merge (2.2)

git merge 远程跟踪分支,例如 git merge origin/master

  • 来源(远程跟踪分支 origin/master)+ 当前分支(HEAD 指向的本地分支,如 feature)= NewMergeCommit;
  • 更新当前分支(feature)到 NewMergeCommit;
  • 并且 把 NewMergeCommit 的内容写回工作区;
  • 即 C + B -> B; B -> A;(见第1.1节的四个空间)

注:若满足 fast-forward 条件(当前分支是来源的祖先),默认不产生 NewMergeCommit,只把当前分支指针移到来源的 commit(见后文 fast-forward),并且内容写回工作区;

git merge 本地分支,例如 git merge master

  • 同上,只是“来源”不是远程跟踪分支,而是本地分支(master);
  • 若当前分支(HEAD 指向的本地分支)是 feature,则:本地分支(master)+ 本地分支(feature)= NewMergeCommit;
  • 更新当前分支(feature)到 NewMergeCommit;
  • 并且 把 NewMergeCommit 的内容写回工作区;
  • 即 B1(来源) + B2(当前分支)-> B2; B2 -> A;(见第1.1节的四个空间)

rebase (2.3)

git rebase 远程跟踪分支,例如 git rebase origin/master

  • 把当前分支(HEAD 指向的本地分支,如 feature)上的新commits 放到来源(远程跟踪分支 origin/master)的新 commits 的后面;
  • 不产生 NewMergeCommit;
  • 并且 把内容写回工作区;
  • 也是 C + B -> B; B -> A; 只是没有 NewMergeCommit;

git rebase 本地分支,例如 git rebase master

  • 同上,只是把来源换成本地分支;
  • 也是 B1(来源) + B2(当前分支)-> B2; B2 -> A; 只是没有 NewMergeCommit;

pull (2.4)

git pull 远程名 远程分支,例如 git pull origin master

  • 默认(未配置 pull.rebase、pull.ff 等)等价于 git fetch origin master + git merge origin/master;
  • 实际行为受配置影响:
    • pull.rebase=true 时相当于 git pull --rebase;
    • pull.ff=only 时只允许 fast-forward,分叉则失败;
  • 新版本 git 在两边分叉且未配置上述选项时,会报错:要求明确指定 merge 还是 rebase;

git pull –rebase 远程名 远程分支,例如 git pull –rebase origin master

  • 等价于 git fetch origin master + git rebase origin/master

对比 merge 与 rebase (2.5)

  • merge “来源”到“当前分支”

    • 保留两方的提交顺序,合并产生 NewMergeCommit;
    • 双方的 commit hash (commit id) 都不变;
    • 当前分支指向 NewMergeCommit;
    • 分叉后合并,历史有分支形状;
    • 适用:合并功能分支到主分支(保留完整的分支历史,方便追溯)、多人协作的共享分支(不改写历史,避免冲突)
    • 可以对已经推送到远程的分支做;
  • 把“当前分支” rebase 到“来源”之上

    • “来源”的 commits 在前,当前分支的 commits 在后;保留各自的相对提交顺序(把当前分支上的 commits 整体“搬到”来源分支的顶端);不产生 NewMergeCommit;
    • “来源”的 commit hash (commit id) 不变;当前分支独有、被搬动(重放)的那些 commits 会得到新的 hash;
    • 分叉点之前的共同 commits 不变;若当前分支本来就基于来源的顶端,则什么都不变;
    • 当前分支指向最后的commit(当前分支的最新commit、被排在最后);
    • 历史被重写,变成一条直线;
    • 适用:本地开发时同步远程最新代码(保持线性历史,干净清爽)、个人分支还没 push(整理提交记录,让历史更清晰)
    • 不要对已经推送到远程的分支做!因为会改写历史,别人拉的时候会冲突!
  • 总之:rebase 是“整理自己的历史”,merge 是“整合别人的历史”;自己的、还没 push 的 commits,随便 rebase;别人的、已经 push 的 commits,用 merge。

对比 fast-forward 与 non-fast-forward (2.6)

  • fast-forward:
1
2
3
4
5
6
7
8
9
10
11
12
13
          HEAD                                    HEAD         
| |
V V
master (只是master指针移动) master
| |
V |
[+]--[+]--[+] ==> [+]--[+]--[+] |
\ \ |
\ \ V
[+]--[+] [+]--[+]
^ ^
| |
dev dev
  • non-fast-forward (–no-ff 选项):
    • 不满足 fast-forward 条件(当前分支有来源没有的新提交,即两边分叉)或者满足但 merge 时添加 --no-ff 选项;
    • 注意:能否 fast-forward 只看提交历史的祖先关系,与是否冲突无关;分叉后合并才可能出现冲突;
    • 会产生一个 MergeCommit;
    • 好处是,保留分支信息;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
                         HEAD                                          HEAD 
| |
V V
master master
| |
V |
----[e]----[g] ----[e]----[g] |
/ / | |
/ / | V
[a]--[b]--[c] ===> [a]--[b]--[c] -------[M]
\ \ / (MergeCommit)
\ \ /
[d]----[f] [d]----[f]
^ ^
| |
dev dev


HEAD 是什么?

HEAD 就是 .git/HEAD 这个文件,它记录“我当前在哪里”。它有两种形态:

  • 正常状态(attached):里面存的是一个分支名,即所谓的符号引用(symbolic ref):
1
2
cat .git/HEAD
ref: refs/heads/master

这是一次间接:HEAD -> 分支名 -> commit id。

  • detached HEAD(游离状态):里面直接存一个 commit id,不经过任何分支:
1
2
cat .git/HEAD
d50b5fb40bc328e920210403c940c548c8a83c8f

HEAD 的两个作用

  1. 提交时的父 commit:新 commit 的 parent 就是 HEAD 当前所指的 commit;
  2. 决定提交后移动谁:正常状态下,提交会让 HEAD 所指的那个分支指向新 commit(HEAD 本身的内容不变,仍是 ref: refs/heads/xxx);detached 状态下,移动的是 HEAD 自己。

同理,git reset 正常状态下移动的是当前分支,detached 状态下移动的是 HEAD(见前文 reset 小节)。


查看 HEAD

1
2
3
4
5
cat .git/HEAD              # 看原始内容,能直接区分两种形态
git symbolic-ref HEAD # 打印 refs/heads/master;detached 时报错 "ref HEAD is not a symbolic ref"
git branch --show-current # 打印 master;detached 时输出为空
git rev-parse HEAD # 两种形态都打印最终的 commit id(会自动解引用)
git status -sb # 正常时显示 ## master...;detached 时显示 ## HEAD (no branch)

注意 git rev-parse HEAD 总能拿到 commit id,所以不能用它判断是否 detached;要判断就用上面前三条。


基于 HEAD 的 commit 坐标写法

写法 含义
HEAD 当前 commit
HEAD^、HEAD^1 第一个父 commit
HEAD^2 第二个父 commit(只有 MergeCommit 才有,指向被合并进来的那一方)
HEAD~1 等同 HEAD^,往上一代
HEAD~2 往上两代,即 HEAD^^(始终沿第一父走,不会拐到被合并的分支上)
HEAD@{2} reflog 里 HEAD 两次变动之前的位置(git reflog 可查看)
ORIG_HEAD 上一次 reset/merge 等危险操作之前 HEAD 的位置,用于反悔

以第2.6节 non-fast-forward 那张图为例,master 指向 MergeCommit [M] 时:HEAD^1 是 [g](master 方向),HEAD^2 是 [f](dev 方向),HEAD~2 是 [e](继续沿第一父)。


detached HEAD:怎么进入、有什么风险

1
2
3
4
git switch --detach <commit>   # 显式进入游离状态(推荐写法)
git checkout <commit> # 老写法,效果相同
git switch - # 回到上一个分支(HEAD 恢复为 ref: refs/heads/xxx)
git switch master # 或直接切到某个分支

查看历史提交的内容、二分查找(git bisect)时会处于这个状态,本身没有问题。风险在于此时的提交没有任何分支指向它:

1
2
3
* (HEAD detached from d50b5fb) 3089de8 orphan-commit   # 新 commit 只被 HEAD 指着
dev a475d8f d1
master 2e584d2 merge

一旦切走,HEAD 改指别处,这个 commit 就不再被任何引用指向,git log 看不到,久置会被 gc 清理(短期内还能用 git reflog 找回)。所以在 detached 状态下做了提交又想保留,切走之前先给它一个分支:

1
git switch -c new-branch       # 把当前 HEAD 所指的 commit 固定到一个分支上

git reset –hard/–soft/–mixed CommitID

三种模式都会把当前分支(reset 前 HEAD 所指的那个分支)移到 CommitID;HEAD 本身不变,仍是 ref: refs/heads/<当前分支>。区别在于是否重置 index 和工作区。被移走的 commits 还可以通过 ORIG_HEAD 或 git reflog 找回。

注意:CommitID 只是“移到哪里”的目标,与它属于哪个分支的历史无关;reset 不会切换分支。例如当前在 dev 分支,对 master 历史上的某个 commit 执行 reset:

1
2
3
4
5
6
7
8
9
10
11
# reset 前
HEAD -> ref: refs/heads/dev # .git/HEAD 的内容
dev -> [d1]
master -> [m2]

git reset --hard [m2]

# reset 后
HEAD -> ref: refs/heads/dev # 没变,仍指向 dev,不会切到 master
dev -> [m2] # 被移动的是 dev
master -> [m2] # 不受影响

此时 git log --decorate 会显示 [m2] (HEAD -> dev, master):dev 和 master 同时指向 [m2],但当前分支仍是 dev,之后的提交只会移动 dev。

若处于 detached HEAD(HEAD 直接存着一个 commit id,而不是 ref: refs/heads/...),则 reset 移动的是 HEAD 自己,不涉及任何分支。

  • hard: 回到 CommitID,工作区也会被重置,已跟踪文件中未提交的修改(包括已 add 的)都会丢失!
    • 当前分支移到目标 commit
    • index(暂存区)重置为目标的快照,已 add 的内容被清除
    • 工作区重置为目标的快照,工作区修改被覆盖
    • 未跟踪(untracked)文件不受影响
  • soft: 回到 CommitID + 从 CommitID 到当前 index 的 diff(staged)
    • 不改 index,也不改工作区,所以不丢失任何修改
    • 从 CommitID 到当前状态的 committed 修改:变成 staged 状态
    • 从 CommitID 到当前状态的 staged 修改:仍为 staged 状态
    • 从 CommitID 到当前状态的 unstaged 修改:仍为 unstaged 状态
    • 注意:若同一文件既有被撤销的提交修改,又有之后的 staged 修改,二者合并为一份 staged 修改(中间版本只保留在原 commit 里)
  • mixed(默认模式): 回到 CommitID + 从 CommitID 到工作区的 diff(unstaged)
    • 工作区不变;index 重置为目标的快照
    • 从 CommitID 到当前状态的 committed 修改:变成 unstaged 状态(工作区中有这些内容)
    • 从 CommitID 到当前状态的 staged 修改:变成 unstaged 状态(前提是工作区中也有这些内容,否则会丢失见下面注意点)
    • 从 CommitID 到当前状态的 unstaged 修改:仍为 unstaged 状态
    • 注意:不保证不丢失:只存在于暂存区、与工作区不同的内容会丢失(例如 add 之后又在工作区把它改掉或删掉)

reset 的本质:给引用赋值

reset 做的事只有两步:

  1. 把当前分支这个引用的值改成 CommitID(detached HEAD 时改的是 HEAD 自己);
  2. 按模式决定是否把 index、工作区刷成该 commit 的快照(见上面三种模式)。

“赋值”意味着它对 CommitID 没有任何历史要求:

  • CommitID 可以是任意形式的 commit 坐标:HEAD~2、cc591ad、master、origin/master、other 都一样,git 只解析出一个 commit id;
  • 不要求与当前分支有共同祖先。即使两条历史毫无交集(如 git switch --orphan 造出来的分支),reset 也会成功;
  • 对比:merge 需要找 merge-base 做三方合并(无关历史还要 --allow-unrelated-histories),rebase 需要找分叉点来决定重放哪些 commits —— 它们都依赖历史关系,reset 不依赖。

reset 不是切换分支

git reset --hard other 执行后,当前分支仍然是 dev,只是 dev 指向了 other 所指的那个 commit:

命令 改的是什么 之后当前分支
git switch other HEAD(ref: refs/heads/dev -> ref: refs/heads/other) other
git reset --hard other dev 这个引用的值(指向 other 的 commit) 仍是 dev

即 switch 动 HEAD、不动分支指针;reset 动分支指针、不动 HEAD。

reset 之后,git log dev 看到的是 other 那条历史(因为历史是从分支指针往父提交回溯出来的),dev 原来的 commits 从 dev 不再可达。两个分支此刻指向同一 commit,但之后提交只会移动 dev。

所以“把当前分支 reset 到另一个分支上”是可行的,但它的含义是“让当前分支改指过去、丢弃自己原来的线”,不是“切过去”。这类操作要当心:

  • 原有 commits 只能靠 ORIG_HEAD / git reflog 找回,git log 已经看不到,久置会被 gc 清理;
  • 这是改写历史,分支若已 push,再推需要 --force,会影响已拉取该分支的人;
  • 用 --soft/--mixed 时工作区不变,会产生一个“删掉对方文件、新增自己文件”的巨大 diff;
  • 日常想换开发基线,用 git switch 或 git rebase --onto;reset 的常规用途是在同一条线上前后挪动。

git switch [-c] BranchName

  • 切换到分支 BranchName;
  • 选项 -c 表示 create(新建新分支并切换到它);所以,切换到已存在分支不加-c;
  • 取代 git checkout(不要再用 git checkout BranchName)!

提交 DAOS PR 用到的 git 命令 (3)

以一次真实投稿为例:Jira 编号 DAOS-19869,功能分支 DAOS-19869-manual-discard,官方仓库 daos-stack/daos,个人 Fork yuanguohuo/daos。
命令本身是通用的,换项目只需替换编号、分支名和仓库地址。


两个远端的分工

开源项目基本都是「Fork + PR」模型,本地仓库要登记两个远端:

远端 地址 权限 用途
origin github.com/yuanguohuo/daos(个人 Fork) 可读可写 push 功能分支
upstream github.com/daos-stack/daos(官方) 只读 fetch 官方最新代码

数据流向是一个环:

1
2
官方仓库 ──fetch──> 本地仓库 ──push──> 个人 Fork ──PR──> 官方仓库
(upstream) (origin)

本地不直接往官方 push(也没有权限),代码进官方的唯一途径是 PR。
origin 和 upstream 只是约定俗成的名字,没有任何特殊语义,就是 .git/config 里的两段配置(见第 1 节)。


一、建仓与确定起点

1
2
3
4
5
6
mkdir -p "$HOME/dev"
git clone --branch master https://github.com/yuanguohuo/daos.git "$HOME/dev/daos"
cd "$HOME/dev/daos"
git remote add upstream https://github.com/daos-stack/daos.git
git fetch upstream master
git switch --no-track -c DAOS-19869-manual-discard upstream/master

逐条解读:

  • clone --branch master <Fork 地址>:Fork → 新建的本地仓库。clone 会自动把来源远端命名为 origin,并写入第 1 节那条 fetch refspec,于是 refs/remotes/origin/* 一并建好。--branch master 只决定 clone 完 checkout 哪个分支。投稿需要完整历史,不要用 --depth。
  • remote add upstream <官方地址>:仅在 .git/config 里加一段地址配置,不下载任何代码。
  • fetch upstream master:官方库的 master → 本地对象库,并更新远程跟踪分支 upstream/master。它不动任何本地分支,也不会更新你 Fork 上的 master。所谓“Fork 落后于官方”对投稿没有影响,因为基线取自 upstream/master 而不是 Fork。
  • switch --no-track -c <分支名> upstream/master:以刚取到的官方基线新建功能分支并切换工作区。--no-track 是关键:不加它,git 会顺手把这个分支的上游配成 upstream/master(即第 1 节的 branch.*.remote / branch.*.merge),之后无参数的 git push / git pull 目标就错了——应该对自己的 Fork,而不是官方。

分支名用 DAOS-<Jira 编号>-<主题>,这是 DAOS 的约定;别的项目看它自己的贡献指南。

起点确认用四条只读命令,各回答一个问题:

1
2
3
4
git branch --show-current     # 我在哪个分支?
git status -sb # 工作区干净吗?与上游差几个提交?
git remote -v # origin / upstream 指向对不对?
git log -1 --oneline # 基线 commit 是哪个?

二、提交前:看清三个空间

改完代码,先确认「哪些改动会进入这次提交」。三条命令分别看三个不同的 diff(对照第1.1节的四个空间):

1
2
3
git status --short      # 总览
git diff # 工作区 vs index(未暂存的)
git diff --cached # index vs HEAD(已暂存的,也就是 commit 的内容)

git status --short 每行开头是两列 XY:X 是 index 相对 HEAD,Y 是工作区相对 index。

输出 含义
M f 改了但没暂存
M f 已暂存,之后没再改
MM f 暂存后又改了一次(两份不同内容)
A f 新文件已暂存
?? f 未跟踪文件,普通 git diff 看不到它

暂存:

1
2
git add -u              # 只把已跟踪文件的修改/删除加入 index
git add src/新文件.c # 新文件必须显式指定,add -u 不管它

不要用 git add .:会把测试日志、覆盖率报告、补丁文件、个人配置一起塞进提交。

一个常见误解:git add 之后 git diff 变空,不代表改动丢了——它只是跑到 index 里去了,用 git diff --cached 看。

确定 PR 改动范围要用分叉点,而不是 HEAD^:

1
2
3
PR_BASE="$(git merge-base HEAD upstream/master)"
git diff --name-status "$PR_BASE" # 这个 PR 动了哪些文件
git diff --check "$PR_BASE" # 只检查行尾空白、空白错误

git merge-base A B 给出两条线的最近共同祖先。HEAD^..HEAD 只在「PR 恰好一个提交」时才等价,多提交的 PR 用它会漏掉前面的提交。

另外注意 git diff --check 只查空白问题,代码风格要另外的工具,例如:

1
git clang-format --diff "$PR_BASE" -- src/pool/srv_pool.c   # 只打印建议,不改源码

三、创建提交

1
2
3
4
5
6
7
8
9
10
11
12
GIT_AUTHOR_NAME='Your Name' \
GIT_AUTHOR_EMAIL='you@example.com' \
GIT_COMMITTER_NAME='Your Name' \
GIT_COMMITTER_EMAIL='you@example.com' \
git commit -s \
-m "DAOS-19869 pool: add manual discard before reintegration" \
-m "Automatic discard during reintegration can trigger NVMe garbage
collection that competes with user I/O, causing severe performance
degradation or temporary service unavailability.

Add dmg pool discard for asynchronous cleanup and a configurable
quiesce period, and dmg pool query-discard for per-rank progress."

解读:

  • 四个环境变量写在命令前面,只对这一条命令生效,不改全局也不改仓库配置。适用场景:本机默认身份是公司邮箱,而社区投稿要用个人公开身份。
  • GIT_AUTHOR_* 是原作者(代码是谁写的),GIT_COMMITTER_* 是本次创建提交的人。平时两者相同;rebase、amend、别人代为提交时会不同。两个都要核对。
  • 多个 -m 按顺序拼成提交信息:第一个是标题,其余是正文段落,中间自动空一行。DAOS 要求标题含 Jira 编号和 component、不超过 62 字符,正文按约 70 列换行。
  • -s 自动添加 Signed-off-by: 一行,表示 DCO 声明(我有权提交这段代码)。它不是 GPG 签名(那是 -S),也不会让 github 显示 Verified。DAOS 的 DCO 检查就看这一行。
  • 邮箱会随提交永久公开。可以用 github 提供的 noreply 地址,但不能自己编一个。
  • 本机如果装了 hook(拼写检查、版权行、凭据泄露扫描),让它正常跑。不要用 --no-verify 绕过,拦住了就去处理原因。

提交后确认:

1
2
3
git log -1 --format=fuller    # 同时显示 Author 和 Commit(committer),核对身份
git show --stat HEAD # 这个提交到底动了哪些文件、多少行
git status --short # 应该是空的

git log -1 默认只显示 Author,看不到 committer;--format=fuller 才会把两者都列出来。用环境变量改身份时,必须用它核对,否则很容易出现 Author 改了、Committer 还是公司邮箱的情况。


四、推送到 Fork

1
2
git remote -v                 # 再确认一次 origin 是自己的 Fork,别推到官方
git push -u origin DAOS-19869-manual-discard

解读:

  • 这条等价于 git push origin refs/heads/DAOS-19869-manual-discard:refs/heads/DAOS-19869-manual-discard。按第 1 节的镜像规则,push 时 <src> 在本地、<dst> 在远程——所以左边才是你的本地分支。远端没有这个分支时会新建,回显 [new branch]。
  • -u(--set-upstream)在 .git/config 写入这个分支的上游配置,指向 origin/DAOS-19869-manual-discard。之后 git push / git pull 可以不带参数,git status -sb 也能报 ahead/behind。第一次推送加 -u,之后不用再加。
  • push 只上传已提交的历史:没提交的测试日志、覆盖率报告不会上去;但已经提交进历史的敏感信息会上去,而且删不干净。
  • HTTPS push 提示 Password 时,要的是令牌(token)而不是 github 登录密码。令牌不要写进仓库 URL、代码或文档。
  • push 不会自动创建 PR。

验证推送结果,两个层次别混:

1
2
3
git status -sb                                             # 本地的"认知"
git rev-parse HEAD # 本地真实值
git ls-remote origin refs/heads/DAOS-19869-manual-discard # 远端真实值

git status -sb 的 ahead/behind 是把本地分支和本地的 origin/<分支> 比,而那只是上次 fetch/push 时的快照——远端被别人改了,它也不知道。要确认远端真实指向,只能用 git ls-remote:它直接问远端,并且不写本地任何引用。本次核对到 Fork 上的 SHA 与本地 HEAD 同为 e313e8a10…,才确认不用再推一次。

创建 PR 时选四个字段:base repository = daos-stack/daos、base = master、head repository = yuanguohuo/daos、compare = 功能分支。对应的比较页面:

1
https://github.com/daos-stack/daos/compare/master...yuanguohuo:DAOS-19869-manual-discard?expand=1

五、PR 之后

跟进官方更新:

1
2
git fetch upstream master                             # 只更新 upstream/master,功能分支一行不动
git merge-base --is-ancestor upstream/master HEAD # 退出码 0:我的分支已含上游最新;1:上游又前进了

第二条实测过:功能分支从 master 分出来时退出码是 0,master 又有新提交后变成 1。注意它只回答「是否包含」,不改任何东西。

真要吸收上游更新,再做 merge 或 rebase(项目有偏好就按项目的来),然后重新验证——不要为每次上游变动自动 rebase;PR 没冲突、维护者没要求时,先推进 CI 和 review 更重要。

按 review 意见改代码时:

1
2
git commit --amend          # 修补上一个提交,SHA 会变
git push --force-with-lease origin DAOS-19869-manual-discard
  • --amend 不是「修改」原提交,而是用新内容造一个新提交替换它,所以 SHA 必然变(旧的还能在 reflog 里找到)。
  • 远端已有旧提交,新历史不是它的后代,普通 push 会被拒(non-fast-forward,见第2.6节的 fast-forward 条件)。
  • 用 --force-with-lease 而不是 --force:前者会先检查「远端现在的值是否还是我上次见到的值」,若别人动过就拒绝推送,避免覆盖别人的提交。
  • 收到 non-fast-forward 报错时先搞清原因,不要条件反射地 force。

命令速查

命令 一句话解读
git remote add upstream <url> 只登记地址,不下载代码
git fetch upstream master 官方 → 本地对象库 + upstream/master,不动本地分支
git switch --no-track -c <分支> upstream/master 以官方基线建分支,且不把上游设成官方
git status --short 两列 XY:index 相对 HEAD、工作区相对 index
git diff / git diff --cached 工作区 vs index / index vs HEAD
git add -u 只收已跟踪文件的改动,新文件要显式 add
git merge-base HEAD upstream/master 求分叉点,确定 PR 的真实改动范围
git commit -s 加 Signed-off-by(DCO),不是 GPG 签名
git log -1 --format=fuller 唯一能同时看到 Author 和 Committer 的常用写法
git push -u origin <分支> 本地分支 → Fork 同名分支,并写上游配置
git ls-remote origin <ref> 直接问远端的真实指向,不写本地引用
git push --force-with-lease 改写历史后安全地重推

写的不错,有赏!