Git

参考:

  • https://git-scm.com/docs

  • https://missing.csail.mit.edu/

  • https://www.ruanyifeng.com/blog/2015/12/git-cheat-sheet.html

  • ...

第 6 课:Git

本节课的讲法是先大致讲清 Git 的数据模型(实现),再讲操作命令。个人认为很受用,强烈推荐。这里仅记录 Git 数据模型等内部相关的东西,关于 Git 的使用参见这里

一个数据模型的例子如下:

<root> (tree)
|
+- foo (tree)
|  |
|  + bar.txt (blob, content = "hello world")
|
+- baz.txt (blob, content = "git is wonderful")

blob、tree、commit、object、reference

注:这些东西都可以在 .git 目录中探索。

在 Git 中,文件被称作 blob。目录被称作 tree。commit 包含了父 commit,提交信息、作者、以及本次提交的 tree 等信息。object 则可以是前三者的任意一个。进一步,对每个 object 进行哈希,用哈希值代表每个 object。注意 object 是不可变的,而 reference 是一个映射(指针),字符串(例如 master)映射到 object 的哈希值。所以,一个仓库可以看作是一堆 object 与一堆 reference(即objects 与 references),Git 命令大多数是在操作 object 或者 reference,具体地说,是增加 object ;增加/删除/修改 reference(即改变其指向)。伪代码如下:

workspace、stage、version

以下参杂个人理解:这三者实际上都是一个版本/快照,而所谓的快照基本上等同于一个 tree/commit 对象。

workspace 表示工作区,也就是 .git 目录以外的所有内容,workspace 可以看作是一个快照;

stage 是为了方便用户使用的一个机制,比如说开发了一个新特性,将其放入缓冲区,之后再增加了一些调试代码,那么提交时可以不提交调试代码。stage 中的信息被保存在了 .git/INDEX 文件内,stage 可以看作是一个快照;

version 则是历史提交的版本,因此实际上是若干个快照。

许多命令例如:git addgit diffgit restore 实际上就是利用上述三者之一修改/比较另外一个或多个快照。

branch、HEAD

每个 branch 都是一条直线的提交序列(一系列的 commit 对象),每个 branch 都有一个名字,例如 master、dev 等,它们指向其中的一次提交。git commit 命令实际上的作用是生成一个 commit 对象后,将生成的 commit 对象的父亲设置为当前的分支名指向的提交,而后将分支名指向的对象设置为新生成的对象,伪代码如下:

而 HEAD 指的是当前状态下的最近一次的提交。通常情况下,HEAD 总是与某个分支名的指向相同。但在某些情况下,HEAD 指向的提交不与任何的分支名的指向一致,称为 detached 的状态,例如使用类似如下的方式切换分支:

本质上而言,分支名与 HEAD 都是 reference 对象中的元素,存储的都是一个特定的 commit_id。而整个版本库存放的东西无外乎是一堆 commit 对象(每个 commit 对象包含指向其父亲的指针以及一个 tree 对象)。

.git 目录

依次执行

得到如下目录(省略了一些目录及文件)

注释项代表该条命令运行之后生成了该文件。除了 objects 目录以及 index 文件外,其余均为文本文件。为了获取 objects 目录及 index 文件的内容,可以使用以下两行命令:

结论:

  • git add 命令只会生成 blob 对象

  • git commit 命令会同时生成 tree 和 commit 对象

  • HEAD 指向某个分支名,git checkout <分支名> 会同时修改 HEADindex 的内容,并且切换分支时 git status 的结果必须为 clean,否则无法执行。

object 的 hash 值

一个 blob 对象的hash值的计算过程如下:

假定 readme.txt 文件内容为123,它被 git add 的时候,objects 目录下会增加一个以二进制序列命名的文件

一共40位(加密算法为SHA-1),其中前两位为目录名,后38位为文件名

使用 python 可以用如下方式计算出来:

术语

  • stage/cache/index/缓冲区/暂存区都是指的同一个东西

Git 命令简介

所有与 Git 有关的命令最好用 git bash 打开。当然,将 git.exe 所在目录加入到了 path 环境变量后,使用普通的 shell(例如:cmd, powershell, bashrc 等)基本也都没问题。所有的 Git 命令除了 git initgit clone 外,一般都要在 .git 的同级目录下执行。

git init

其效果是为当前目录建立一个 .git 目录,正常情况下不要去修改这个文件夹下的任何内容,可以这样理解:之后的每一条以 git 开头的命令执行后,git.exe 会依据命令内容对 .git 目录下的文件或依据 .git 目录内的文件对工作区进行修改。注意:

  • git init 命令不一定需要在空目录,是否在空目录下执行产生的 .git 目录内容是一样的。

  • 若 .git 目录已存在, 使用 git init 命令的结果会重新初始化,一般不会这样用

git config

设置提交commit信息时的用户名及邮箱

备注: 此项设定与远程代码库的拉取/推送权限无关

设置http协议远程库记住密码

git log

以下命令用于显示所有的提交信息

git reflog

git status

输出分为两部分:

  • Changes not staged for commit: 显示工作区相对暂存区的变动记录

  • Changes to be committed: 显示暂存区相对最近一次提交的变动记录

用下面的命令可以简化输出:

左边的 M 表示工作区文件修改了(相对最近一次提交)并且放入了暂存区, 右边的 M 表示工作区文件修改了但是没有放入暂存区。此处表示 README 修改了,但是还没有使用 git add README 命令将其放入暂存区;lib/simplegit.rb 被修改后放入了工作区,之后未被修改过;Makefile 在工作区被修改后放入了暂存区,而后工作区又做了修改。

左边的 A 表示 lib/git.rb 是工作区相对最近一次提交新增的文件,并已经放入了暂存区。

?? 表示 LICENSE.txt 是工作区相对最近一次提交新增的文件,但没有放入暂存区中。

git diff

如果需要查看两个 commit 中某个文件的差异,可以使用如下

例子及解释如下

  • source_commit_id 的行用 - 进行标识,target_commit_id 的行用 + 进行标识,没有变化的行用空格进行标识。

  • 显示方式按差异块的形式呈现:

    表示的是 source_commit_id 的第 [6, 6+12-1] 行的内容与 target_commit_id 的第 [6, 6+8-1] 行的内容有差异。

git add/rm

git add 的作用是为工作区产生变化的文件生成 blob,并将这些文件添加至暂存区

  • 如果 filename 在工作区存在,且与暂存区中的内容不一致或暂存区中没有该文件。具体执行过程为:首先为 filename 创建一个 object (blob)放在 .git/objects 下,之后将该 object 放入暂存区。

  • 如果 filename 在工作区中不存在,且在暂存区中存在,那么效果等同于 git rm <filename>。具体执行过程为:将暂存区中相应的 object 删除

git rm 的主要作用是在暂存区中删除文件,可以通过不同的参数选择是否也删除工作区中的文件

git commit

git commit 命令表示将当前的暂存区放入版本库中,前者会打开 Git 默认的文本编辑器(可以使用 git config 进行设置)供开发者添加描述信息,在公司里开发项目推荐用这种方式。后者一般用于添加简略的描述信息,适用于不那么正式的个人项目中使用。

git reset

参考 Pro Git 7.7 节

git reset 操作当前分支的三种操作如下

下面分别说明上面三条命令在做什么,首先假定当前分支为 foo,那么此时 HEAD 也指向了 foo

  • git reset --soft HEAD^ 表示的意思是将 foo 的指针指向次新一次的提交,而 HEAD 依然指向 foo

  • git reset --mixed HEAD^ 表示的意思是在上一步的基础上,使用 foo 指向的提交更新暂存区

  • git reset --hard HEAD^ 表示在执行前两步后,用暂存区的内容再更新工作区,这样三个区域便完全一致了

备注:这里有一个用法可以用在代码审查中,不确定是否为最佳实践。

VSCode中工作区相对于暂存区的修改在代码行的左侧有 gutter indicators 进行标识,现在假设原始仓库的地址为:https://github.com/base/project,而开发者foo将此代码库进行了 fork 操作,仓库地址为:https://github.com/foo/project。经过代码修改过,首先更新了自己仓库的 feature 分支,之后提出 Pull Request 合并至原始仓库的 dev 分支。此时可以用如下方式在本地看出代码修改了哪些部分

git branch

显示已有分支

创建删除分支

为分支设定 upstream 分支,在 Git 中,每个分支至多只能有一个 upstream 分支。注意:这里的 upstream 与数据模型中的 parent 是不同的概念。设定后可以不加参数地使用 git pull/push/fetch。

类似地,可以使用如下命令来新建本地分支,并与远程分支关联。所谓建立关联, 就是关联后可以不加参数(不指定远程分支名)地使用 git pull/push/fetch

git stash

git stash 用于暂存一些文件,但不进行提交。参见例 4。

git checkout/switch/restore

git checkout 命令用于切换分支以及文件的版本切换作用

git checkout 用于切换分支

本质上是修改 HEAD 指向的 commit_id

注意两条命令只有一些微妙的区别:第一条命令如果切换后 HEAD 与现有的某个分支名的指向一致,则 HEAD 的状态为非 detached 状态,否则为 detached 状态;第二条命令切换后必然处于 detached 状态。所谓 detached 状态指的是切换后相当于处于匿名分支上,如果在匿名分支上发生了提交,之后又切换到别的分支,如果还想切换回匿名分支,那么只能用 commit_id 来切换回去。

当工作目录发生了修改,但此时希望切换到另一个分支,并且要切换到的分支与当前工作目录的版本存在冲突时,使用 git checkout 命令将会失败,此时如果使用

那么等效于先丢弃工作区的全部修改,再进行分支切换

git checkout 用于文件版本切换

较新版本的 Git 引入了两个命令将 checkout 的两大功能进行了分离。其中 git switch 用于分支切换,git restore 用于文件的版本切换。

git switch

git restore

git merge

分支合并的一般流程为:

git rebase(待研究)

原理

从合并的文件上看与 merge 效果一样,但提交历史有了改变。

假定分支情况为:

使用 git rebase 的流程为:

效果是 dev 分支的提交历史变为

所谓 rebase 的直观含义是将 dev 的“基” 从 c2 修改为了 master分支的 c5。使用变基得到的另一个好处是切换回 master 分支后将 dev 分支合进来就不用解决冲突(不产生任何 git object,仅仅是修改了 git ref)

rebase 与 merge 的区别

上述过程如果只用 merge,流程为

效果是

实际上,merge c5 和 c7 的过程为,对 c5 和 c7 对应的 tree 进行合并,解决冲突后使用 git add 命令时,会得到一些新的 blob,使用 git merge --continue 时,会用 .git/INDEX(暂存区)里对应的 tree 写入 .git/objects 目录,并得到一个新的 commit 对象 c8,也写入 .git/objects 目录。注意:c8 这个 commit 对象的 parent 有两个,即 c5 和 c7。

而 rebase 的过程为:对 c5 和 c7 进行合并后得到的 commit 对象 c8 的 parent 仅有 c5 一个。另外,还会产生一个新的 commit 对象 c6’,其提交信息与 c6 一致,但其 parent 与 c6 不同,并且所对应的 tree 对象也有所不同。

git cherry-pick

上述命令的作用是,将 <commit-id> 相对于它前一次提交的修改,作用到当前分支 dev 上,并形成一次新的提交。注意:假设 <commit-id> 对应于另一个分支例如 master,新的提交依旧可能与 master 分支有合并冲突。

将commit-id2相对于commit-id1的连续多个修改作用于当前分支

git clone

上述两条命令内部的详细过程为:两者都会将 .git 中的所有内容(远程仓库的所有分支)下载到本地。下载后,第二条命令本地的默认分支 dev 由远程分支 origin/dev 产生。

git remote

git submodule

参考:pro-git 7.11

参考:git-tower(一个不错的教程,感觉比 pro-git 还要清晰)

有时候,项目开发时需要引入另一个项目,并希望同时保留两个项目各自的提交历史,此时需要使用 git submodule 命令

例如:在项目 a 中需要引入 https://github.com/example/b.git 作为子模块,可以使用:

此时,a/ 目录下会多出一个 .gitmodules 文件,并且多出一个 a/b 文件夹,文件目录类似如下:

此时 a/.git 目录不记录 b 目录的具体修改(只关心在对 a 提交时 a/b 的 commit id 是否发生变化)。git 命令在 a/ 目录与 a/b 目录下分别只对 a/.gita/b/.git 进行修改,并且只关心各自部分的文件修改历史。

如果需要克隆一个带有 submodule 的仓库,可以使用如下几种方式进行:

git fetch/pull/push

git pull 的具体行为是:首先 git fetch 指定远程分支的更新,之后在指定的本地分支上进行 git merge 的操作,如果当前的 HEAD 刚好位于指定的本地分支上,则移动 HEAD 的指向到 merge 后的位置。

git fetch 命令只会将远程分支的修改更新到 .git 目录内部,但不会新建分支,也不会对本地的工作目录进行修改。

参考 stackoverflow 问答,可以按如下方式拉取所有远程分支的更新:

git bundle

bundle 文件用于离线传输 git objects. 使用上可以参考 文档 中的例子, 但由于实际使用时与例子中的需求不完全匹配, 所以这里复述一下文档中的例子(部分), 并加以补充. bundle 文件可以理解为一个离线的 repo, 可以将其视为受限的“远程仓库”, 进行 git remote add, git pull, git fetch, 但与真正的远程仓库的重要区别是: 不能对 bundle 文件进行 git push.

假定场景是这样: 在网络环境 1 的机器 A 上有一个仓库 R1, 在网络环境 2 上有一个机器 B, 在时刻 T1, 将 R1 通过某种方式复制去了机器 B 的 R2, 随后 R1 继续做了修改/提交, R2 可能也继续做了修改/提交. (可以理解 R1 是某个 GitHub 上的开源项目, R2 为公司在确认开源协议后, 决定基于此项目做二次开发). 现在在 T2 时刻, 希望将 R1 在 T1 时刻之后的所有修改 (git objects) 保存在一个文件 F 中, 然后将 F 拷贝至机器 B, 然后 R2 仓库按需从 F 中 cherry-pick/merge/fetch 等.

文档 中的解决方案

T1 时刻同步

此步骤是 T1 时刻将 R1 “复制” 到机器 B, 首先在机器 A 上操作

file.bundle 通过某种方式复制进

T2 时刻同步

上述描述的 T2 时刻同步的做法是与 T1 时刻同步方式匹配的, 实际情况中 T1 时刻的同步还存在这几种情况:

  • 将整个 R1 仓库打包为一个压缩包, 传入内网后解压以获取 R2 仓库

  • R1 仓库本身也有远程库例如 GitHub, 而 R1 仓库在 T1 至 T2 期间, 使用 git fetchgit pull 更新 git objects 时, 有许多远程分支 remotes/origin/xx 没有相对应的本地分支 xx

这种情况下, T2 时刻的同步也需要做些适当的调整, 这里有一个示例, 但需要确保自己知道每一步在做啥:

这个例子需要理解这个 stackoverflow问答 以及这个 简书博客

T1 时刻同步

将整个 R1 仓库打包为一个压缩包, 传入内网后解压以获取 R2 仓库

T2 时刻同步

备注: 参考这个 blog, git lfs 文件似乎没法使用 git bundle 做迁移

* git cat-file

查看 .git/objects 目录下的文件内容

* git ls-files

* git ls-remote

输出

git lfs

使用如下方式对大文件进行特殊管理(由 git lfs 管理)

git clone 时可以选择是否下载大文件

git clean

慎用

常用命令备忘录

不切换分支拉取远程代码与本地分支做fast-forward合并

http协议记住密码

删除分支

疑难杂症

一般使用

代理问题

关于代理引发的 git clone 失败问题,参考链接

重置代理

根据实际端口情况修改

一次性使用

网络问题

暂未找到解决方法

终端显示分支

在 terminal 中提示当前所在分支,将如下代码段加入到 ~/.bashrc 中即可

http 免密

详例

注意, 测试2.1与2.2表示的是在测试1的基础上尝试两种做法的结果

例 1(待清晰化)

本例着重解释了git status/diff/restore三个命令

测试 1

总结:

git status命令的输出分为两部分:

  • Changes not staged for commit: 显示工作区相对暂存区的变动记录

  • Changes to be committed: 显示暂存区相对最近一次提交的变动记录

相应地, git diff命令地解释如下:

测试 2

总结: 接下来, 在操作之前先解释git restore的两种用法

测试 2.1

测试 2.2

测试 2.3

总结: git restore还有第三种用法如下

测试如下:

测试 2.3.1

测试2.3.2

根据上面的总结, 解释如下过程:

例 2(待补充)

本例着重解释 git rm 命令

备注: 本机已经准备好了如下环境

例 3(待删减)

由于被各种命令搞晕, 于是决定干脆打开.git目录一探究竟, 难免会有许多错误, 待日后修改

当所有操作仅限于本地时, .git目录大概长这样:

以下为一个完整的测试(我们主要关注logs, objects, refs文件夹以及index, HEAD, ORIG_HEAD文件的变化)

step 1

注意: 此时logs文件夹与ORIG_HEAD未被创建, objects目录下还没有字节码文件

HEAD文件的内容为

refs/heads/master文件还未被创建

step 2

在工作区增加一个文件show_git.py

此时, objects目录下新增了一个./74/ba2162d599d4e44dcf3d7811cbbd84d43e911d字节码文件(对应于新增的show_git.py文件), index文件夹也做了更新.

refs/heads/master文件还未被创建

备注: objects目录下计算出的16进制值只与文件内容与文件名有关

step 3

此时.git目录的变化为:

  • log目录被创建, 目录结构如下

    master与HEAD的文件内容如下

    若此时使用git flag pretty=oneline命令, 输出结果为:

  • index目录做更新

  • refs目录下的heads/master文件被创建, 内容如下

    注意: 这个值大概与时间有关(怀疑是uuid)

  • objects目录新增了两个文件7b/9a4ced4ae9fc215a7cbb2e1c52b6fce706e051, 92/628406280a94f4efbdcbf59dcb60a5b44ab124

    不知道为什么要新增两个文件, 有一个疑惑是git reflog命令需要的输出保存在哪, 是否与这个有关?

  • HEAD文件内容保持不变

例 4(git stash 与 git merge 综合实例)

适用场景如下,例如:master 分支为线上分支,现在需要开发一个新功能,则基于该分支创建一个 dev 分支,但还没修改完毕并且不想提交时,发现 master 分支上出现了 bug,需紧急修复。此时直接使用 git branch master 会报错,此时可以使用 git stash 命令将改动的文件暂存,这样便可以正常切换分支。完整过程如下

例 5 (代码库同步)

场景设定: 公司内网与 GitHub 网络不通, 公司内网存在类似于 Gitlab 的代码管理服务器, 数据文件可从外网上传至内网服务器. 现在希望对 GitHub 项目手动与内网项目同步.

方案1 (不合适, 也没有真正实践过)【待确认!!!】

参考:

  • https://github.com/git-lfs/git-lfs/issues/2342

步骤如下:

第一次同步

  • 外网需保留 xx.git 目录(以增量更新)

  • 内网需保留 xx.git 目录(以增量更新), xx 目录如果不需要可以删除

  • 内网需保留 <TARGET-URL>(以增量更新)

第二次同步(更新)

例 6: bare repo 与正常 repo 转换

参考: stackoverflow

正常 repo -> bare repo

bare repo -> 正常 repo

Git 合作模式

模式一:

master 分支只用作合并,且合并过程自动完成,无需解决冲突。dev 分支用做开发人员的公共基库,各开发人员(例如:f1,f2 分支)完成相应的开发后,在 dev 分支上完成手动解决冲突后的合并。最后将 dev 分支合并至 master 分支。

GitHub Flow

在 Github 上为他人项目贡献代码的工作流如下:

  • 在 GitHub 网页的功能在官方原始仓库新建 issue

  • 首先 fork 官方原始仓库到自己的个人远程仓库

  • git clone 个人远程仓库本地仓库, 务必从希望最终被合入的官方分支新建分支进行开发

  • 如果官方原始仓库发生修改, 则使用 GitHub 网页的功能将个人远程仓库官方原始仓库同步

  • git pull/fetch 将个人远程仓库的更新内容拉至本地仓库

  • 本地仓库的新建分支与官方分支合并

  • 使用 GitHub 网页的功能提 PR 请求, PR 请求关联 issue

Git hooks

hooks 通常译为“钩子”,Git hooks 本质上是位于 .git/hooks 下的一些脚本,它们会在特定的事件触发时(也就是某些特定的命令被执行时)被自动运行,例如:执行 git commit 命令时。其文件名是固定的(对应着相应的事件),git 默认为每个仓库都提供了默认的 hooks,它们的扩展名均为 .sample,如果需要启用 hooks,只需要将相应脚本的扩展名删除即可。hooks 的特点是在 git clone 时,这些脚本不会被克隆下来,另外默认 hooks 的语言为 shell 脚本,但也可以使用其他脚本语言例如 Python,只需要修改文件的 shebang 行即可。

一个看起来还不错的教程

注意:不要为了加 hooks 而加 hooks,它只是一个工具。

github、gitlab

Github 对比同一仓库的两个分支: https://github.com/username/Repo_A/compare/29ea234..84ae245

Git Internals

git reset 与 git checkout

git add 的时候,将新增加的文件内容加入至 .git/objects 目录,而不增加 tree 类型的 object。在 git commit 的时候才创建 tree 类型的 object,并将其添加至 .git/objects 目录。

git resetgit checkout 的区别:

git reset 的调用方式有如下几种

其中前三条命令的执行逻辑是依次进行如下三步:

  • 将当前的 branch 指向 , HEAD 依旧指向 branch, 因此最终也指向

  • 使用 中的内容覆盖暂存区的内容

  • 使用暂存区的内容覆盖工作区的内容

备注: 只有第三种被认为是危险的

而后两条命令的执行逻辑是:

  • 使用 中的覆盖暂存区的版本

  • 使用暂存区的版本覆盖工作区的内容

git checkout 的调用方式有如下几种

第一条命令的执行逻辑是:

  • 将HEAD指针本身指向, 将暂存区的内容改为中的内容, 将中的内容与工作区的内容合并(如果有冲突, 命令本身会报错)

特殊目录与文件

  • .git: Git 仓库

  • .github: github 网页端的 issue, pull requests 等模板

  • .gitattribute: Git-LFS 管理的文件信息

  • .gitignore: git 忽略文件

Last updated

Was this helpful?