Git基础

本文最后更新于:2022年5月29日 凌晨

前言

这么久时间还是只会 git pushgit clone , 一碰到分支有关的就退缩了。 大概知道是个 DAG , 简而言之, 我患上了新人分支恐惧症

Git 学习笔记

本篇文章基本为 pro git 的读书笔记

一、 起步

基于差异的版本控制:

image-20211124105734292

而 git 是保存文件的快照, 如下图

image-20211124105821061

  • git 的所有操作都是本地执行
  • git 保证完整性
  • git 一般只添加数据,这意味着 git 几乎不会执行任何可能导致文件不可恢复的操作

三种状态

image-20211124110448297

运行 git 前的配置

一般来说, 有三个级别的配置文件

  1. /etc/gitconfig , 作用于系统上每个用户, 使用参数 --system
  2. ~/.gitconfig , 只针对当前用户, 使用参数 --global
  3. 所在仓库的配置文件, .git/config

每个级别都会覆盖上一级别

使用命令 git config --list --show-origin 查看所有配置和来源的文件

安装 git 后, 首先需要配置 user.nameuser.email , 因为每次 commit 都要用到这些信息

1
2
$ git config --global user.name "John Doe"
$ git config --global user.email johndoe@example.com

配置默认文本编辑器

1
$ git config --global core.editor emacs

使用 git config xxx 检查某项属性

1
2
$ git config user.name
John Doe

也可以使用 alias 进行自定义快捷指令

1
alias.st=status

git st 代替 git status

二、 基础

获取仓库

git init 或者 git clone

文件状态

image-20211124141643955

可以使用 git status 来查看状态, 可以使用 -s

1
2
3
4
5
6
$ git status -s
M README
MM Rakefile
A lib/git.rb
M lib/simplegit.rb
?? LICENSE.txt

新添加的未跟踪文件前面有 ?? 标记,新添加到暂存区中的文件前面有 A 标记,修改过的文件前面有 M 标记。 输出中有两栏,左栏指明了暂存区的状态,右栏指明了工作区的状态

忽略文件

gitignore 文件, 忽略的文件列表, 例如产生的一些临时文件, log 文件等

例如

1
2
*.[oa]
*~

第一行告诉 Git 忽略所有以 .o.a 结尾的文件。一般这类对象文件和存档文件都是编译过程中出现的。 第二行告诉 Git 忽略所有名字以波浪符(~)结尾的文件,许多文本编辑软件(比如 Emacs)都用这样的文件名保存副本。

可以使用 git diff 命令查看已暂存和未暂存的修改

image-20211126104605739

这里绿色的 + 表示的是工作区相较于暂存区的变化

使用命令 git diff --staged 可以查看暂存区相较于上一次提交的差异

image-20211126104823977

提交更新

在此之前,请务必确认还有什么已修改或新建的文件还没有 git add 过, 否则提交的时候不会记录这些尚未暂存的变化。 这些已修改但未暂存的文件只会保留在本地磁盘。 所以,每次准备提交前,先用 git status 看下,你所需要的文件是不是都已暂存起来了, 然后再运行提交命令 git commit

跳过暂存区

commit 加上 -a 参数, git commit -am "xxxx" 这样

移除文件

要从 Git 中移除某个文件,就必须要从已跟踪文件清单中移除(确切地说,是从暂存区域移除),然后提交。 可以用 git rm 命令完成此项工作,并连带从工作目录中删除指定的文件,这样以后就不会出现在未跟踪文件清单中了。

image-20211126110713905

如果要删除之前修改过或已经放到暂存区的文件,则必须使用强制删除选项 -f(译注:即 force 的首字母)。 这是一种安全特性,用于防止误删尚未添加到快照的数据,这样的数据不能被 Git 恢复。

image-20211126110915610

另外一种情况是,我们想把文件从 Git 仓库中删除(亦即从暂存区域移除),但仍然希望保留在当前工作目录中。 使用 --cached 选项

image-20211126111009949

文件的移动

可以使用 git mv

image-20211126111252666

等效的操作如下

1
2
3
mv src dst
git rm src
git add dst

image-20211126111353903

如此分开操作,Git 也会意识到这是一次重命名,所以不管何种方式结果都一样。 两者唯一的区别在于,git mv 是一条命令而非三条命令,直接使用 git mv 方便得多。 不过在使用其他工具重命名文件时,记得在提交前 git rm 删除旧文件名,再 git add 添加新文件名。

查看提交历史

使用命令 git log 查看提交历史

image-20211126112354953

不传入任何参数的默认情况下,git log 会按时间先后顺序列出所有的提交,最近的更新排在最上面。 正如你所看到的,这个命令会列出每个提交的 SHA-1 校验和、作者的名字和电子邮件地址、提交时间以及提交说明。

其中一个比较有用的选项是 -p--patch ,它会显示每次提交所引入的差异(按 补丁 的格式输出)。 你也可以限制显示的日志条目数量,例如使用 -2 选项来只显示最近的两次提交:

git log -p -2 可以看到如下输出

image-20211126112458580

你也可以为 git log 附带一系列的总结性选项。 比如你想看到每次提交的简略统计信息,可以使用 --stat 选项:

image-20211126112611609

另一个非常有用的选项是 --pretty。 这个选项可以使用不同于默认格式的方式展示提交历史。 这个选项有一些内建的子选项供你使用。 比如 oneline 会将每个提交放在一行显示,在浏览大量的提交时非常有用。 另外还有 shortfullfuller 选项,它们展示信息的格式基本一致,但是详尽程度不一:

image-20211126112733539

最有意思的是 format ,可以定制记录的显示格式。 这样的输出对后期提取分析格外有用——因为你知道输出的格式不会随着 Git 的更新而发生改变:

image-20211126112820599

git log --pretty=format 常用的选项 列出了 format 接受的常用格式占位符的写法及其代表的意义。

选项 说明
%H 提交的完整哈希值
%h 提交的简写哈希值
%T 树的完整哈希值
%t 树的简写哈希值
%P 父提交的完整哈希值
%p 父提交的简写哈希值
%an 作者名字
%ae 作者的电子邮件地址
%ad 作者修订日期(可以用 –date=选项 来定制格式)
%ar 作者修订日期,按多久以前的方式显示
%cn 提交者的名字
%ce 提交者的电子邮件地址
%cd 提交日期
%cr 提交日期(距今多长时间)
%s 提交说明

你一定奇怪 作者提交者 之间究竟有何差别, 其实作者指的是实际作出修改的人,提交者指的是最后将此工作成果提交到仓库的人。 所以,当你为某个项目发布补丁,然后某个核心成员将你的补丁并入项目时,你就是作者,而那个核心成员就是提交者。

onelineformat 与另一个 log 选项 --graph 结合使用时尤其有用。 这个选项添加了一些 ASCII 字符串来形象地展示你的分支、合并历史:

撤销操作

有时候我们提交完了才发现漏掉了几个文件没有添加,或者提交信息写错了。 此时,可以运行带有 --amend 选项的提交命令来重新提交:

1
git commit --amend

这个命令会将暂存区中的文件提交。 如果自上次提交以来你还未做任何修改(例如,在上次提交后马上执行了此命令), 那么快照会保持不变,而你所修改的只是提交信息。

例如,你提交后发现忘记了暂存某些需要的修改,可以像下面这样操作

1
2
3
$ git commit -m 'initial commit'
$ git add forgotten_file
$ git commit --amend

最终你只会有一个提交——第二次提交将代替第一次提交的结果。

修补提交最明显的价值是可以稍微改进你最后的提交,而不会让“啊,忘了添加一个文件”或者 “小修补,修正笔误”这种提交信息弄乱你的仓库历史。

取消暂存的文件

可以使用命令 git reset HEAD 命令

image-20211127210532139

实际上这里给的提示与 progit 当中有所出入

image-20211127210854292

这里给的是 git restore --staged <file>

image-20211127210933488

撤销对文件的修改

image-20211127210759530

gitpro 中使用的是 git checkout -- <file>

记住,在 Git 中任何 已提交 的东西几乎总是可以恢复的。 甚至那些被删除的分支中的提交或使用 --amend 选项覆盖的提交也可以恢复 (阅读 数据恢复 了解数据恢复)。 然而,任何你未提交的东西丢失后很可能再也找不到了。

如果想回到之前的某次提交, 可以查看之前提交的 hash 值, 然后使用 get reset --hard xxx

image-20211206000336600

image-20211206000412358

这个时候已经看不到之后的提交记录了, 可以使用 git reflog 查看

image-20211206000517576

然后再使用 get reset --hard xxx 可回退

image-20211206000635432

远程仓库

查看远程仓库

可以使用 git remote 查看

image-20211129100018619

添加新的远程仓库

运行 git remote add <shortname> <url> 添加一个新的远程 Git 仓库,同时指定一个方便使用的简写:

1
2
3
4
5
6
7
8
$ git remote
origin
$ git remote add pb https://github.com/paulboone/ticgit
$ git remote -v
origin https://github.com/schacon/ticgit (fetch)
origin https://github.com/schacon/ticgit (push)
pb https://github.com/paulboone/ticgit (fetch)
pb https://github.com/paulboone/ticgit (push)

现在你可以在命令行中使用字符串 pb 来代替整个 URL。 例如,如果你想拉取 Paul 的仓库中有但你没有的信息,可以运行 git fetch pb

从远程仓库抓取

如果你使用 clone 命令克隆了一个仓库,命令会自动将其添加为远程仓库并默认以 “origin” 为简写。 所以,git fetch origin 会抓取克隆(或上一次抓取)后新推送的所有工作。 必须注意 git fetch 命令只会将数据下载到你的本地仓库——它并不会自动合并或修改你当前的工作。 当准备好时你必须手动将其合并入你的工作。

如果你的当前分支设置了跟踪远程分支, 那么可以用 git pull命令来自动抓取后合并该远程分支到当前分支。 这或许是个更加简单舒服的工作流程。默认情况下,git clone 命令会自动设置本地 master 分支跟踪克隆的远程仓库的 master 分支(或其它名字的默认分支)。 运行 git pull 通常会从最初克隆的服务器上抓取数据并自动尝试合并到当前所在的分支。

推送到远程仓库

git push <remote> <branch>。 当你想要将 master 分支推送到 origin 服务器时(再次说明,克隆时通常会自动帮你设置好那两个名字), 那么运行这个命令就可以将你所做的备份到服务器:

1
$ git push origin master

只有当你有所克隆服务器的写入权限,并且之前没有人推送过时,这条命令才能生效。 当你和其他人在同一时间克隆,他们先推送到上游然后你再推送到上游,你的推送就会毫无疑问地被拒绝。 你必须先抓取他们的工作并将其合并进你的工作后才能推送。

查看某个远程仓库

如果想要查看某一个远程仓库的更多信息,可以使用 git remote show <remote> 命令。 如果想以一个特定的缩写名运行这个命令,例如 origin,会得到像下面类似的信息:

截屏2021-11-29 上午10.12.21

这个命令列出了当你在特定的分支上执行 git push 会自动地推送到哪一个远程分支。 它也同样地列出了哪些远程分支不在你的本地,哪些远程分支已经从服务器上移除了, 还有当你执行 git pull 时哪些本地分支可以与它跟踪的远程分支自动合并。

远程仓库的重命名和删除操作

你可以运行 git remote rename 来修改一个远程仓库的简写名。 例如,想要将 pb 重命名为 paul,可以用 git remote rename 这样做:

1
2
3
4
$ git remote rename pb paul
$ git remote
origin
paul

值得注意的是这同样也会修改你所有远程跟踪的分支名字。 那些过去引用 pb/master 的现在会引用 paul/master

如果因为一些原因想要移除一个远程仓库——你已经从服务器上搬走了或不再想使用某一个特定的镜像了, 又或者某一个贡献者不再贡献了——可以使用 git remote removegit remote rm

1
2
3
$ git remote remove paul
$ git remote
origin

打标签

列出标签

git tag , 或者使用 -l 参数使用通配符模式

创建标签

Git 支持两种标签:轻量标签(lightweight)与附注标签(annotated)。

轻量标签很像一个不会改变的分支——它只是某个特定提交的引用。

而附注标签是存储在 Git 数据库中的一个完整对象, 它们是可以被校验的,其中包含打标签者的名字、电子邮件地址、日期时间, 此外还有一个标签信息,并且可以使用 GNU Privacy Guard (GPG)签名并验证。 通常会建议创建附注标签,这样你可以拥有以上所有信息。但是如果你只是想用一个临时的标签, 或者因为某些原因不想要保存这些信息,那么也可以用轻量标签。

附注标签

使用 -a 选项

1
2
3
4
5
$ git tag -a v1.4 -m "my version 1.4"
$ git tag
v0.1
v1.3
v1.4

-m 选项指定了一条将会存储在标签中的信息。 如果没有为附注标签指定一条信息,Git 会启动编辑器要求你输入信息。

轻量标签

另一种给提交打标签的方式是使用轻量标签。 轻量标签本质上是将提交校验和存储到一个文件中——没有保存任何其他信息。 创建轻量标签,不需要使用 -a-s-m 选项,只需要提供标签名字:

1
2
3
4
5
6
7
$ git tag v1.4-lw
$ git tag
v0.1
v1.3
v1.4
v1.4-lw
v1.5

这时,如果在标签上运行 git show,你不会看到额外的标签信息。

后期打标签

你也可以对过去的提交打标签。 假设提交历史是这样的:

1
2
3
4
5
6
7
8
9
10
11
$ git log --pretty=oneline
15027957951b64cf874c3557a0f3547bd83b3ff6 Merge branch 'experiment'
a6b4c97498bd301d84096da251c98a07c7723e65 beginning write support
0d52aaab4479697da7686c15f77a3d64d9165190 one more thing
6d52a271eda8725415634dd79daabbc4d9b6008e Merge branch 'experiment'
0b7434d86859cc7b8c3d5e1dddfed66ff742fcbc added a commit function
4682c3261057305bdd616e23b64b0857d832627b added a todo file
166ae0c4d3f420721acbb115cc33848dfcc2121a started write support
9fceb02d0ae598e95dc970b74767f19372d61af8 updated rakefile
964f16d36dfccde844893cac5b347e7b3d44abbc commit the todo
8a5cbc430f1a9c3d00faaeffd07798508422908a updated readme

现在,假设在 v1.2 时你忘记给项目打标签,也就是在 “updated rakefile” 提交。 你可以在之后补上标签。 要在那个提交上打标签,你需要在命令的末尾指定提交的校验和(或部分校验和):

1
$ git tag -a v1.2 9fceb02

共享标签

默认情况下,git push 命令并不会传送标签到远程仓库服务器上。 在创建完标签后你必须显式地推送标签到共享服务器上。 这个过程就像共享远程分支一样——你可以运行 git push origin <tagname>

如果想要一次性推送很多标签,也可以使用带有 --tags 选项的 git push 命令。 这将会把所有不在远程仓库服务器上的标签全部传送到那里。

1
2
3
4
5
6
7
$ git push origin --tags
Counting objects: 1, done.
Writing objects: 100% (1/1), 160 bytes | 0 bytes/s, done.
Total 1 (delta 0), reused 0 (delta 0)
To git@github.com:schacon/simplegit.git
* [new tag] v1.4 -> v1.4
* [new tag] v1.4-lw -> v1.4-lw

删除标签

要删除掉你本地仓库上的标签,可以使用命令 git tag -d <tagname>。 例如,可以使用以下命令删除一个轻量标签:

1
2
$ git tag -d v1.4-lw
Deleted tag 'v1.4-lw' (was e7d5add)
1
$ git push origin --delete <tagname>

别名

一些例子

1
2
3
4
5
$ git config --global alias.co checkout
$ git config --global alias.br branch
$ git config --global alias.ci commit
$ git config --global alias.st status
$ git config --global alias.unstage 'reset HEAD --'

在创建你认为应该存在的命令时这个技术会很有用。 例如,为了解决取消暂存文件的易用性问题,可以向 Git 中添加你自己的取消暂存别名:

1
$ git config --global alias.unstage 'reset HEAD --'

这会使下面的两个命令等价:

1
2
$ git unstage fileA
$ git reset HEAD -- fileA

这样看起来更清楚一些。 通常也会添加一个 last 命令,像这样:

1
$ git config --global alias.last 'log -1 HEAD'

三、分支

分支管理

首先回顾一下 mit6.null 这门课中, git 相关的内容:

Git 将顶级目录中的文件和文件夹作为集合,并通过一系列快照来管理其历史记录。在Git的术语里,文件被称作Blob对象(数据对象),也就是一组数据。目录则被称之为“树”,它将名字与 Blob 对象或树对象进行映射(使得目录中可以包含其他目录)。快照则是被追踪的最顶层的树。例如,一个树看起来可能是这样的:

这里的树应该可以理解成目录, blob 对象也就是文件

1
2
3
4
5
6
7
<root> (tree)
|
+- foo (tree)
| |
| + bar.txt (blob, contents = "hello world")
|
+- baz.txt (blob, contents = "git is wonderful")

简单的数据模型表示

首先是 blob 对象, blob 对象就是一组数据

1
type blob = array<byte>

tree 对象包含了目录(tree)和文件(blob)

1
type tree = map<string, tree | blob>

commit 对象包含了若干父亲结点和作者,提交信息等元数据,还有一个顶层的 tree 对象

1
2
3
4
5
6
type commit = struct {
parent: array<commit>
author: string
message: string
snapshot: tree
}

我们把 blob , commit , tree 统称为对象(object )

1
type object = blob | tree | commit

我们对这些对象进行存储的时候使用 hash

1
2
3
4
5
6
7
8
objects = map<string, object>

def store(object):
id = sha1(object)
objects[id] = object

def load(id):
return objects[id]

现在,所有的快照都可以通过它们的 SHA-1 哈希值来标记了。但这也太不方便了,谁也记不住一串 40 位的十六进制字符。

针对这一问题,Git 的解决方法是给这些哈希值赋予人类可读的名字,也就是引用(references)。引用是指向提交的指针。与对象不同的是,它是可变的(引用可以被更新,指向新的提交)。例如,master 引用通常会指向主分支的最新一次提交。

1
2
3
4
5
6
7
8
9
10
11
12
13
references = map<string, string>

def update_reference(name, id):
references[name] = id

def read_reference(name):
return references[name]

def load_reference(name_or_id):
if name_or_id in references:
return load(references[name_or_id])
else:
return load(name_or_id)

这样,Git 就可以使用诸如 “master” 这样人类可读的名称来表示历史记录中某个特定的提交,而不需要在使用一长串十六进制字符了。

在 Git 中,我们当前的位置有一个特殊的索引,它就是 HEAD

最后,我们可以粗略地给出 Git 仓库的定义了:对象引用

在硬盘上,Git 仅存储对象和引用:因为其数据模型仅包含这些东西。所有的 git 命令都对应着对提交树的操作,例如增加对象,增加或删除引用。

运行

1
2
$ git add README test.rb LICENSE
$ git commit -m 'The initial commit of my project'

Git 会先计算每一个子目录(本例中只有项目根目录)的校验和, 然后在 Git 仓库中这些校验和保存为树对象。随后,Git 便会创建一个提交对象, 它除了包含上面提到的那些信息外,还包含指向这个树对象(项目根目录)的指针。 如此一来,Git 就可以在需要的时候重现此次保存的快照。

image-20211205203355488

[首次提交对象及其树结构]

如果我们做了修改之后再提交, 那么这次产生的提交对象会包含一个指向上次提交对象(父对象)的指针。

image-20211205203458154

Git 的分支,其实本质上仅仅是指向提交对象的可变指针。

Git 的默认分支名字是 master。 在多次提交操作之后,你其实已经有一个指向最后那个提交对象的 master 分支。 master 分支会在每次提交时自动向前移动。

image-20211205203541855

Git 是怎么创建新分支的呢? 很简单,它只是为你创建了一个可以移动的新的指针。 比如,创建一个 testing 分支, 你需要使用 git branch 命令:

1
$ git branch testing

这会在当前所在的提交对象上创建一个指针。

image-20211205203618695

那么,Git 又是怎么知道当前在哪一个分支上呢? 也很简单,它有一个名为 HEAD 的特殊指针。

在 Git 中,它是一个指针,指向当前所在的本地分支(译注:将 HEAD 想象为当前分支的别名)。 在本例中,你仍然在 master 分支上。 因为 git branch 命令仅仅 创建 一个新分支,并不会自动切换到新分支中去。

image-20211205203707873

分支切换

使用 git checkout 命令,

1
$ git checkout testing

这样 HEAD 就指向 testing 分支了。

image-20211205204123946

这次如果我们再提交一次

1
2
$ vim test.rb
$ git commit -a -m 'made a change'

image-20211205204215243

HEAD 分支随着提交操作自动向前移动。

如图所示,你的 testing 分支向前移动了,但是 master 分支却没有,它仍然指向运行 git checkout 时所指的对象。

现在我们切换回 master 分支看看:

1
$ git checkout master

image-20211205204310904

这条命令做了两件事。 一是使 HEAD 指回 master 分支,二是将工作目录恢复成 master 分支所指向的快照内容。 也就是说,你现在做修改的话,项目将始于一个较旧的版本。 本质上来讲,这就是忽略 testing 分支所做的修改,以便于向另一个方向进行开发。

我们不妨再稍微做些修改并提交:

1
2
$ vim test.rb
$ git commit -a -m 'made other changes'

现在,这个项目的提交历史已经产生了分叉

image-20211205204419047

由于 Git 的分支实质上仅是包含所指对象校验和(长度为 40 的 SHA-1 值字符串)的文件,所以它的创建和销毁都异常高效。 创建一个新分支就相当于往一个文件中写入 41 个字节(40 个字符和 1 个换行符),如此的简单能不快吗?

这与过去大多数版本控制系统形成了鲜明的对比,它们在创建分支时,将所有的项目文件都复制一遍,并保存到一个特定的目录。 完成这样繁琐的过程通常需要好几秒钟,有时甚至需要好几分钟。所需时间的长短,完全取决于项目的规模。 而在 Git 中,任何规模的项目都能在瞬间创建新分支。 同时,由于每次提交都会记录父对象,所以寻找恰当的合并基础(译注:即共同祖先)也是同样的简单和高效。 这些高效的特性使得 Git 鼓励开发人员频繁地创建和使用分支。

( DAG 上的 LCA 大概)

分支的新建与合并

让我们来看一个简单的分支新建与分支合并的例子,实际工作中你可能会用到类似的工作流。 你将经历如下步骤:

  1. 开发某个网站。
  2. 为实现某个新的用户需求,创建一个分支。
  3. 在这个分支上开展工作。

正在此时,你突然接到一个电话说有个很严重的问题需要紧急修补。 你将按照如下方式来处理:

  1. 切换到你的线上分支(production branch)。
  2. 为这个紧急任务新建一个分支,并在其中修复它。
  3. 在测试通过之后,切换回线上分支,然后合并这个修补分支,最后将改动推送到线上分支。
  4. 切换回你最初工作的分支上,继续工作。

新建分支

首先,我们假设你正在你的项目上工作,并且在 master 分支上已经有了一些提交。

image-20211205204811709

现在,你已经决定要解决你的公司使用的问题追踪系统中的 #53 问题。 想要新建一个分支并同时切换到那个分支上,你可以运行一个带有 -b 参数的 git checkout 命令:

1
2
$ git checkout -b iss53
Switched to a new branch "iss53"

image-20211205204850105

当你做了一些修改并提交后, iss53 分支向前推进

image-20211205204941872

当你接到紧急电话的时候, 你需要做的就是切换回 master 分支, 但是需要注意保存 iss53 上的未提交的内容, 它可能会和你即将检出的分支产生冲突从而阻止 Git 切换到该分支。 最好的方法是,在你切换分支之前,保持好一个干净的状态。

现在,我们假设你已经把你的修改全部提交了,这时你可以切换回 master 分支了:

1
2
$ git checkout master
Switched to branch 'master'

这个时候,你的工作目录和你在开始 #53 问题之前一模一样,现在你可以专心修复紧急问题了。 请牢记:当你切换分支的时候,Git 会重置你的工作目录,使其看起来像回到了你在那个分支上最后一次提交的样子。 Git 会自动添加、删除、修改文件以确保此时你的工作目录和这个分支最后一次提交时的样子一模一样。

接下来,你要修复这个紧急问题。 我们来建立一个 hotfix 分支,在该分支上工作直到问题解决:

1
2
3
4
5
6
$ git checkout -b hotfix
Switched to a new branch 'hotfix'
$ vim index.html
$ git commit -a -m 'fixed the broken email address'
[hotfix 1fb7853] fixed the broken email address
1 file changed, 2 insertions(+)

你可以运行你的测试,确保你的修改是正确的,然后将 hotfix 分支合并回你的 master 分支来部署到线上。 你可以使用 git merge 命令来达到上述目的:

image-20211205205543518

1
2
3
4
5
6
$ git checkout master
$ git merge hotfix
Updating f42c576..3a0874c
Fast-forward
index.html | 2 ++
1 file changed, 2 insertions(+)

在合并的时候,你应该注意到了“快进(fast-forward)”这个词。 由于你想要合并的分支 hotfix 所指向的提交 C4 是你所在的提交 C2 的直接后继, 因此 Git 会直接将指针向前移动。换句话说,当你试图合并两个分支时, 如果顺着一个分支走下去能够到达另一个分支,那么 Git 在合并两者的时候, 只会简单的将指针向前推进(指针右移),因为这种情况下的合并操作没有需要解决的分歧——这就叫做 “快进(fast-forward)”。

image-20211205205636688

关于这个紧急问题的解决方案发布之后,你准备回到被打断之前时的工作中。 然而,你应该先删除 hotfix 分支,因为你已经不再需要它了 —— master 分支已经指向了同一个位置。 你可以使用带 -d 选项的 git branch 命令来删除分支:

1
2
$ git branch -d hotfix
Deleted branch hotfix (3a0874c).

现在你可以切换回你正在工作的分支继续你的工作,也就是针对 #53 问题的那个分支(iss53 分支)。

1
2
3
4
5
6
$ git checkout iss53
Switched to branch "iss53"
$ vim index.html
$ git commit -a -m 'finished the new footer [issue 53]'
[iss53 ad82d7a] finished the new footer [issue 53]
1 file changed, 1 insertion(+)

image-20211205205858142

分支的合并

假设你已经修正了 #53 问题,并且打算将你的工作合并入 master 分支。 为此,你需要合并 iss53 分支到 master 分支,这和之前你合并 hotfix 分支所做的工作差不多。 你只需要检出到你想合并入的分支,然后运行 git merge 命令:

1
2
3
4
5
6
$ git checkout master
Switched to branch 'master'
$ git merge iss53
Merge made by the 'recursive' strategy.
index.html | 1 +
1 file changed, 1 insertion(+)

这和你之前合并 hotfix 分支的时候看起来有一点不一样。 在这种情况下,你的开发历史从一个更早的地方开始分叉开来(diverged)。 因为,master 分支所在提交并不是 iss53 分支所在提交的直接祖先,Git 不得不做一些额外的工作。 出现这种情况的时候,Git 会使用两个分支的末端所指的快照(C4C5)以及这两个分支的公共祖先(C2),做一个简单的三方合并。

image-20211205210746024

image-20211205212424111

既然你的修改已经合并进来了,就不再需要 iss53 分支了。 现在你可以在任务追踪系统中关闭此项任务,并删除这个分支。

1
$ git branch -d iss53

遇到冲突时的分支合并

如果相对于 LCA, 两个分支对同一个文件做了不同的修改, Git 就没办法干净的合并它们, 即合并时遇到了冲突。

image-20211205215032819

打开冲突文件的时候, 会出现如下的类似信息

image-20211205215109839

可以使用图形化工具 SourceTree 进行可视化

image-20211205231118823

分支管理

git branch 查看所有分支, git branch -v 查看所有分支最后一次提交

--merged--no-merged 这两个有用的选项可以过滤这个列表中已经合并或尚未合并到当前分支的分支。

查看所有包含未合并工作的分支,可以运行 git branch --no-merged

1
2
$ git branch --no-merged
testing

这里显示了其他分支。 因为它包含了还未合并的工作,尝试使用 git branch -d 命令删除它时会失败:

1
2
3
$ git branch -d testing
error: The branch 'testing' is not fully merged.
If you are sure you want to delete it, run 'git branch -D testing'.

如果真的想要删除分支并丢掉那些工作,如同帮助信息里所指出的,可以使用 -D 选项强制删除它。

分支开发流

长期分支

因为 Git 使用简单的三方合并,所以就算在一段较长的时间内,反复把一个分支合并入另一个分支,也不是什么难事。 也就是说,在整个项目开发周期的不同阶段,你可以同时拥有多个开放的分支;你可以定期地把某些主题分支合并入其他分支中。

许多使用 Git 的开发者都喜欢使用这种方式来工作,比如只在 master 分支上保留完全稳定的代码——有可能仅仅是已经发布或即将发布的代码。 他们还有一些名为 develop 或者 next 的平行分支,被用来做后续开发或者测试稳定性——这些分支不必保持绝对稳定,但是一旦达到稳定状态,它们就可以被合并入 master 分支了。 这样,在确保这些已完成的主题分支(短期分支,比如之前的 iss53 分支)能够通过所有测试,并且不会引入更多 bug 之后,就可以合并入主干分支中,等待下一次的发布。

事实上我们刚才讨论的,是随着你的提交而不断右移的指针。 稳定分支的指针总是在提交历史中落后一大截,而前沿分支的指针往往比较靠前。

image-20211205231954935

通常把他们想象成流水线(work silos)可能更好理解一点,那些经过测试考验的提交会被遴选到更加稳定的流水线上去。

image-20211205232009357

你可以用这种方法维护不同层次的稳定性。

主题分支

主题分支对任何规模的项目都适用。 主题分支是一种短期分支,它被用来实现单一特性或其相关工作。 也许你从来没有在其他的版本控制系统(VCS)上这么做过,因为在那些版本控制系统中创建和合并分支通常很费劲。 然而,在 Git 中一天之内多次创建、使用、合并、删除分支都很常见。

考虑这样一个例子,你在 master 分支上工作到 C1,这时为了解决一个问题而新建 iss91 分支,在 iss91 分支上工作到 C4,然而对于那个问题你又有了新的想法,于是你再新建一个 iss91v2 分支试图用另一种方法解决那个问题,接着你回到 master 分支工作了一会儿,你又冒出了一个不太确定的想法,你便在 C10 的时候新建一个 dumbidea 分支,并在上面做些实验。 你的提交历史看起来像下面这个样子:

image-20211205232241243

现在,我们假设两件事情:你决定使用第二个方案来解决那个问题,即使用在 iss91v2 分支中方案。 另外,你将 dumbidea 分支拿给你的同事看过之后,结果发现这是个惊人之举。 这时你可以抛弃 iss91 分支(即丢弃 C5C6 提交),然后把另外两个分支合并入主干分支。 最终你的提交历史看起来像下面这个样子:

合并了 `dumbidea` 和 `iss91v2` 分支之后的提交历史。

当你做这么多操作的时候,这些分支全部都存于本地。 当你新建和合并分支的时候,所有这一切都只发生在你本地的 Git 版本库中 —— 没有与服务器发生交互。

远程分支

远程引用是对远程仓库的引用(指针),包括分支、标签等等。 你可以通过 git ls-remote <remote> 来显式地获得远程引用的完整列表, 或者通过 git remote show <remote> 获得远程分支的更多信息。 然而,一个更常见的做法是利用远程跟踪分支。

远程跟踪分支是远程分支状态的引用。它们是你无法移动的本地引用。一旦你进行了网络通信, Git 就会为你移动它们以精确反映远程仓库的状态。请将它们看做书签, 这样可以提醒你该分支在远程仓库中的位置就是你最后一次连接到它们的位置。

它们以 <remote>/<branch> 的形式命名。 例如,如果你想要看你最后一次与远程仓库 origin 通信时 master 分支的状态,你可以查看 origin/master 分支。 你与同事合作解决一个问题并且他们推送了一个 iss53 分支,你可能有自己的本地 iss53 分支, 然而在服务器上的分支会以 origin/iss53 来表示。

假设你的网络里有一个在 git.ourcompany.com的 Git 服务器。 如果你从这里克隆,Git 的 clone 命令会为你自动将其命名为 origin,拉取它的所有数据, 创建一个指向它的 master 分支的指针,并且在本地将其命名为 origin/master。 Git 也会给你一个与 origin 的 master 分支在指向同一个地方的本地 master 分支,这样你就有工作的基础。

image-20211205232953560

如果你在本地的 master 分支做了一些工作,在同一段时间内有其他人推送提交到 git.ourcompany.com 并且更新了它的 master 分支,这就是说你们的提交历史已走向不同的方向。 即便这样,只要你保持不与 origin 服务器连接(并拉取数据),你的 origin/master 指针就不会移动。

image-20211205233036926

如果要与给定的远程仓库同步数据,运行 git fetch <remote> 命令(在本例中为 git fetch origin)。 这个命令查找 “origin” 是哪一个服务器(在本例中,它是 git.ourcompany.com), 从中抓取本地没有的数据,并且更新本地数据库,移动 origin/master 指针到更新之后的位置。

image-20211205233118217

为了演示有多个远程仓库与远程分支的情况,我们假定你有另一个内部 Git 服务器,仅服务于你的某个敏捷开发团队。 这个服务器位于 git.team1.ourcompany.com。 你可以运行 git remote add 命令添加一个新的远程仓库引用到当前的项目, 将这个远程仓库命名为 teamone,将其作为完整 URL 的缩写。

image-20211205233418928

现在,可以运行 git fetch teamone 来抓取远程仓库 teamone 有而本地没有的数据。 因为那台服务器上现有的数据是 origin 服务器上的一个子集, 所以 Git 并不会抓取数据而是会设置远程跟踪分支 teamone/master 指向 teamonemaster 分支。

image-20211205233507962

推送

当你想要公开分享一个分支时,需要将其推送到有写入权限的远程仓库上。 本地的分支并不会自动与远程仓库同步——你必须显式地推送想要分享的分支。 这样,你就可以把不愿意分享的内容放到私人分支上,而将需要和别人协作的内容推送到公开分支。

如果希望和别人一起在名为 serverfix 的分支上工作,你可以像推送第一个分支那样推送它。 运行 git push <remote> <branch>:

1
2
3
4
5
6
7
8
$ git push origin serverfix
Counting objects: 24, done.
Delta compression using up to 8 threads.
Compressing objects: 100% (15/15), done.
Writing objects: 100% (24/24), 1.91 KiB | 0 bytes/s, done.
Total 24 (delta 2), reused 0 (delta 0)
To https://github.com/schacon/simplegit
* [new branch] serverfix -> serverfix

这里有些工作被简化了。 Git 自动将 serverfix 分支名字展开为 refs/heads/serverfix:refs/heads/serverfix, 那意味着,“推送本地的 serverfix 分支来更新远程仓库上的 serverfix 分支。” 我们将会详细学习 Git 内部原理refs/heads/ 部分, 但是现在可以先把它放在儿。你也可以运行 git push origin serverfix:serverfix, 它会做同样的事——也就是说“推送本地的 serverfix 分支,将其作为远程仓库的 serverfix 分支” 可以通过这种格式来推送本地分支到一个命名不相同的远程分支。 如果并不想让远程仓库上的分支叫做 serverfix,可以运行 git push origin serverfix:awesomebranch 来将本地的 serverfix 分支推送到远程仓库上的 awesomebranch分支。

这里有些工作被简化了。 Git 自动将 serverfix 分支名字展开为 refs/heads/serverfix:refs/heads/serverfix, 那意味着,“推送本地的 serverfix 分支来更新远程仓库上的 serverfix 分支。” 我们将会详细学习 Git 内部原理refs/heads/ 部分, 但是现在可以先把它放在儿。你也可以运行 git push origin serverfix:serverfix, 它会做同样的事——也就是说“推送本地的 serverfix 分支,将其作为远程仓库的 serverfix 分支” 可以通过这种格式来推送本地分支到一个命名不相同的远程分支。 如果并不想让远程仓库上的分支叫做 serverfix,可以运行 git push origin serverfix:awesomebranch 来将本地的 serverfix 分支推送到远程仓库上的 awesomebranch分支。

下一次其他协作者从服务器上抓取数据时,他们会在本地生成一个远程分支 origin/serverfix,指向服务器的 serverfix 分支的引用:

1
2
3
4
5
6
7
$ git fetch origin
remote: Counting objects: 7, done.
remote: Compressing objects: 100% (2/2), done.
remote: Total 3 (delta 0), reused 3 (delta 0)
Unpacking objects: 100% (3/3), done.
From https://github.com/schacon/simplegit
* [new branch] serverfix -> origin/serverfix

要特别注意的一点是当抓取到新的远程跟踪分支时,本地不会自动生成一份可编辑的副本(拷贝)。 换一句话说,这种情况下,不会有一个新的 serverfix 分支——只有一个不可以修改的 origin/serverfix 指针。

可以运行 git merge origin/serverfix 将这些工作合并到当前所在的分支。 如果想要在自己的 serverfix 分支上工作,可以将其建立在远程跟踪分支之上:

1
2
3
$ git checkout -b serverfix origin/serverfix
Branch serverfix set up to track remote branch serverfix from origin.
Switched to a new branch 'serverfix'

这会给你一个用于工作的本地分支,并且起点位于 origin/serverfix

从一个远程跟踪分支检出一个本地分支会自动创建所谓的“跟踪分支”(它跟踪的分支叫做“上游分支”)。 跟踪分支是与远程分支有直接关系的本地分支。 如果在一个跟踪分支上输入 git pull,Git 能自动地识别去哪个服务器上抓取、合并到哪个分支。

当克隆一个仓库时,它通常会自动地创建一个跟踪 origin/mastermaster 分支。 然而,如果你愿意的话可以设置其他的跟踪分支,或是一个在其他远程仓库上的跟踪分支,又或者不跟踪 master 分支。 最简单的实例就是像之前看到的那样,运行 git checkout -b <branch> <remote>/<branch>。 这是一个十分常用的操作所以 Git 提供了 --track 快捷方式:

1
2
3
$ git checkout --track origin/serverfix
Branch serverfix set up to track remote branch serverfix from origin.
Switched to a new branch 'serverfix'

由于这个操作太常用了,该捷径本身还有一个捷径。 如果你尝试 checkout 出的分支 (a) 不存在且 (b) 刚好只有一个名字与之匹配的远程分支,那么 Git 就会为你创建一个跟踪分支:

1
2
3
$ git checkout serverfix
Branch serverfix set up to track remote branch serverfix from origin.
Switched to a new branch 'serverfix'

如果想要将本地分支与远程分支设置为不同的名字,你可以轻松地使用上一个命令增加一个不同名字的本地分支:

1
2
3
$ git checkout -b sf origin/serverfix
Branch sf set up to track remote branch serverfix from origin.
Switched to a new branch 'sf'

现在,本地分支 sf 会自动从 origin/serverfix 拉取。

如果想要查看设置的所有跟踪分支,可以使用 git branch-vv 选项。 这会将所有的本地分支列出来并且包含更多的信息,如每一个分支正在跟踪哪个远程分支与本地分支是否是领先、落后或是都有。

1
2
3
4
5
$ git branch -vv
iss53 7e424c3 [origin/iss53: ahead 2] forgot the brackets
master 1ae2a45 [origin/master] deploying index fix
* serverfix f8674d9 [teamone/server-fix-good: ahead 3, behind 1] this should do it
testing 5ea463a trying something new

这里可以看到 iss53 分支正在跟踪 origin/iss53 并且 “ahead” 是 2,意味着本地有两个提交还没有推送到服务器上。 也能看到 master 分支正在跟踪 origin/master 分支并且是最新的。 接下来可以看到 serverfix 分支正在跟踪 teamone 服务器上的 server-fix-good 分支并且领先 3 落后 1, 意味着服务器上有一次提交还没有合并入同时本地有三次提交还没有推送。 最后看到 testing 分支并没有跟踪任何远程分支。

需要重点注意的一点是这些数字的值来自于你从每个服务器上最后一次抓取的数据。 这个命令并没有连接服务器,它只会告诉你关于本地缓存的服务器数据。 如果想要统计最新的领先与落后数字,需要在运行此命令前抓取所有的远程仓库。 可以像这样做:

1
$ git fetch --all; git branch -vv

拉取

git fetch 命令从服务器上抓取本地没有的数据时,它并不会修改工作目录中的内容。 它只会获取数据然后让你自己合并。 然而,有一个命令叫作 git pull 在大多数情况下它的含义是一个 git fetch 紧接着一个 git merge 命令。 如果有一个像之前章节中演示的设置好的跟踪分支,不管它是显式地设置还是通过 clonecheckout 命令为你创建的,git pull 都会查找当前分支所跟踪的服务器与分支, 从服务器上抓取数据然后尝试合并入那个远程分支。

由于 git pull 的魔法经常令人困惑所以通常单独显式地使用 fetchmerge 命令会更好一些。

删除远程分支

假设你已经通过远程分支做完所有的工作了——也就是说你和你的协作者已经完成了一个特性, 并且将其合并到了远程仓库的 master 分支(或任何其他稳定代码分支)。 可以运行带有 --delete 选项的 git push 命令来删除一个远程分支。 如果想要从服务器上删除 serverfix 分支,运行下面的命令:

1
2
3
$ git push origin --delete serverfix
To https://github.com/schacon/simplegit
- [deleted] serverfix

基本上这个命令做的只是从服务器上移除这个指针。 Git 服务器通常会保留数据一段时间直到垃圾回收运行,所以如果不小心删除掉了,通常是很容易恢复的。


本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!