Git 简易基础,供自己查看。
基本概念 (1)
仓库结构 (1.1)
1 | project/ |
本地仓库主要包含 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 | [remote "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 | github 上的 project 仓库 本地仓库 |
远程仓库里并没有 refs/remotes/(因为它不需要跟踪你);只有你的仓库才有(因为你要跟踪远程仓库)。所以 refspec 两端不仅方向不同,命名空间也不重合。
可以这样实测(远程有 master、zzz;本地有 master、localonly):
1 | # Case1: 左边写只在远程存在的 zzz —— 成功 |
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 | [branch "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 | git show a1b2c3d |
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 | # 查看当前 HEAD 的完整 id |
因为 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 | cat .git/refs/heads/master |
所以,不要靠 cat 文件判断 ref 的值;用 git rev-parse <ref> 或 git for-each-ref 最可靠:
1 | git for-each-ref --format='%(refname) %(objecttype) %(objectname:short)' |
可以看到,本地分支、远程跟踪分支、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 | git cat-file -t refs/tags/light # commit (轻量 tag 是 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 | cat .git/HEAD # ref: refs/heads/master |
符号 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 | git ls-remote origin |
第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):
.git/<name>(HEAD、ORIG_HEAD 等)refs/<name>refs/tags/<name>refs/heads/<name>refs/remotes/<name>refs/remotes/<name>/HEAD
1 | git rev-parse --symbolic-full-name HEAD # refs/heads/master(HEAD 被解引用) |
tag 排在本地分支前面。若 tag 与分支同名,git 会警告并优先取 tag:
1 | git tag dev # 再建一个与分支 dev 同名的 tag(指向 master 的 26aa42f) |
所以第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 | git rev-parse --symbolic-full-name HEAD~1 # (空) |
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配置,或符合通配符的所有远程分支)!
- 持久化在 .git/config 的 remote 段中:
配置
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);
- 持久化在 .git/config 的 remote 段中:
默认情况下,
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 写入它会被拒)!
- 更新:FETCH_HEAD、远程跟踪分支(若
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 | cat .git/refs/heads/dev |
这里所说的顶端:通过 reset 回到过去某个 commit,那个 commit 就成了顶端。
假如,dev 和 master 分支经过开发、合并、再开发,演进到如下状态:
1 | HEAD |
此时:
1 | .git/refs/heads/master -> [i] 的 commit id |
通常,分支指向它演进路上的最新的 commit,但也可以通过 reset 回到过去。
两个分支可以同时指向一个 commit,这很常见,例如:
1 | git switch 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 | HEAD HEAD |
此时,[d], [f], [h] 不再属于 dev 分支的历史,git log 只会看到 [a], [b], [c], [e].
HEAD (1.10)
HEAD 就是 .git/HEAD 这个文件,它记录“我当前在哪里”。它有两种形态:
- attached(正常状态):里面存的是一个分支名,即符号引用(symbolic ref);
1 | cat .git/HEAD |
间接:HEAD -> 分支名 -> commit id。
- detached(游离状态):里面直接存一个 commit id,不经过任何分支;
1 | cat .git/HEAD |
直接:HEAD -> commit id。
HEAD 有两个作用:
- 提交时的父 commit:新 commit 的 parent 就是 HEAD 当前所指的 commit。
- merge 提交时,HEAD 是第一个 parent;
--amend提交时,新提交沿用的是 HEAD 的 parent,而不是 HEAD 本身;
- 决定提交后移动谁:
- attached 状态:提交会让 HEAD 所指的那个分支指向新 commit;HEAD 本身的内容不变,仍是
ref: refs/heads/xxx; - detached 状态:移动的是 HEAD 自己,即修改 HEAD 里存的 commit id;
- attached 状态:提交会让 HEAD 所指的那个分支指向新 commit;HEAD 本身的内容不变,仍是
attached 状态的提交:
1 | master master |
detached 状态的提交:
1 | master master |
同理,git reset attached 状态下移动的是当前分支;detached 状态下移动的是 HEAD。
attached 状态的 reset,见第1.9节末尾;
detached 状态的 reset:
1 | master HEAD master |
查看 HEAD
1 | cat .git/HEAD # 看原始内容,能直接区分两种形态 |
注意 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 | HEAD |
如何进入、退出 detached 状态:
1 | git switch --detach <commit> # 进入 detached 状态:推荐写法 |
此外,查看历史提交的内容、二分查找(git bisect)、rebase 进行到一半时也会处于 detached 状态。
detached 状态的风险:
风险在于此时的提交没有任何分支指向它:
1 | git branch -v |
一旦切走,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,分叉则失败;
- pull.rebase=true 时相当于
- 新版本 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 | HEAD HEAD |
- non-fast-forward (–no-ff 选项):
- 不满足 fast-forward 条件(当前分支有来源没有的新提交,即两边分叉)或者满足但 merge 时添加
--no-ff选项; - 注意:能否 fast-forward 只看提交历史的祖先关系,与是否冲突无关;分叉后合并才可能出现冲突;
- 会产生一个 MergeCommit;
- 好处是,保留分支信息;
- 不满足 fast-forward 条件(当前分支有来源没有的新提交,即两边分叉)或者满足但 merge 时添加
1 | HEAD HEAD |
HEAD 是什么?
HEAD 就是 .git/HEAD 这个文件,它记录“我当前在哪里”。它有两种形态:
- 正常状态(attached):里面存的是一个分支名,即所谓的符号引用(symbolic ref):
1 | cat .git/HEAD |
这是一次间接:HEAD -> 分支名 -> commit id。
- detached HEAD(游离状态):里面直接存一个 commit id,不经过任何分支:
1 | cat .git/HEAD |
HEAD 的两个作用
- 提交时的父 commit:新 commit 的 parent 就是 HEAD 当前所指的 commit;
- 决定提交后移动谁:正常状态下,提交会让 HEAD 所指的那个分支指向新 commit(HEAD 本身的内容不变,仍是
ref: refs/heads/xxx);detached 状态下,移动的是 HEAD 自己。
同理,git reset 正常状态下移动的是当前分支,detached 状态下移动的是 HEAD(见前文 reset 小节)。
查看 HEAD
1 | cat .git/HEAD # 看原始内容,能直接区分两种形态 |
注意 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 | git switch --detach <commit> # 显式进入游离状态(推荐写法) |
查看历史提交的内容、二分查找(git bisect)时会处于这个状态,本身没有问题。风险在于此时的提交没有任何分支指向它:
1 | * (HEAD detached from d50b5fb) 3089de8 orphan-commit # 新 commit 只被 HEAD 指着 |
一旦切走,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 | # reset 前 |
此时 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 做的事只有两步:
- 把当前分支这个引用的值改成 CommitID(detached HEAD 时改的是 HEAD 自己);
- 按模式决定是否把 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 | 官方仓库 ──fetch──> 本地仓库 ──push──> 个人 Fork ──PR──> 官方仓库 |
本地不直接往官方 push(也没有权限),代码进官方的唯一途径是 PR。origin 和 upstream 只是约定俗成的名字,没有任何特殊语义,就是 .git/config 里的两段配置(见第 1 节)。
一、建仓与确定起点
1 | mkdir -p "$HOME/dev" |
逐条解读:
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 | git branch --show-current # 我在哪个分支? |
二、提交前:看清三个空间
改完代码,先确认「哪些改动会进入这次提交」。三条命令分别看三个不同的 diff(对照第1.1节的四个空间):
1 | git status --short # 总览 |
git status --short 每行开头是两列 XY:X 是 index 相对 HEAD,Y 是工作区相对 index。
| 输出 | 含义 |
|---|---|
M f |
改了但没暂存 |
M f |
已暂存,之后没再改 |
MM f |
暂存后又改了一次(两份不同内容) |
A f |
新文件已暂存 |
?? f |
未跟踪文件,普通 git diff 看不到它 |
暂存:
1 | git add -u # 只把已跟踪文件的修改/删除加入 index |
不要用 git add .:会把测试日志、覆盖率报告、补丁文件、个人配置一起塞进提交。
一个常见误解:git add 之后 git diff 变空,不代表改动丢了——它只是跑到 index 里去了,用 git diff --cached 看。
确定 PR 改动范围要用分叉点,而不是 HEAD^:
1 | PR_BASE="$(git merge-base HEAD upstream/master)" |
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 | GIT_AUTHOR_NAME='Your Name' \ |
解读:
- 四个环境变量写在命令前面,只对这一条命令生效,不改全局也不改仓库配置。适用场景:本机默认身份是公司邮箱,而社区投稿要用个人公开身份。
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 | git log -1 --format=fuller # 同时显示 Author 和 Commit(committer),核对身份 |
git log -1 默认只显示 Author,看不到 committer;--format=fuller 才会把两者都列出来。用环境变量改身份时,必须用它核对,否则很容易出现 Author 改了、Committer 还是公司邮箱的情况。
四、推送到 Fork
1 | git remote -v # 再确认一次 origin 是自己的 Fork,别推到官方 |
解读:
- 这条等价于
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 | git status -sb # 本地的"认知" |
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 | git fetch upstream master # 只更新 upstream/master,功能分支一行不动 |
第二条实测过:功能分支从 master 分出来时退出码是 0,master 又有新提交后变成 1。注意它只回答「是否包含」,不改任何东西。
真要吸收上游更新,再做 merge 或 rebase(项目有偏好就按项目的来),然后重新验证——不要为每次上游变动自动 rebase;PR 没冲突、维护者没要求时,先推进 CI 和 review 更重要。
按 review 意见改代码时:
1 | git commit --amend # 修补上一个提交,SHA 会变 |
--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 |
改写历史后安全地重推 |