从零开始学git

本文中的前世今生部分,参考了蒋鑫的 git权威指南

版本控制前世今生

在史前黑暗时代,人们任由数据自生自灭,相互吞噬,没有版本的概念.

首先出现的是diff和patch. diff比较修改后文件的差异,可以把差异提取出来.

patch则刚好相反,通过修改后的文件和差异文件,反向还原出源文件

CVS--开启版本控制大爆发

CVS(Concurrent Versions System)诞生于l985年,是由荷兰阿姆斯特丹VU大学的Dick Grune教授实现的。当时Dick Grune和两个学生共同开发一个项目,但是三个人的工作时间无法协调到一起,迫切需要一个记录和协同开发的工具软件。于是Dick Grune通过脚本语言对RCS(一个针对单独文件的版本管理工具)进行封装,设计出有史以来第一个被大规模使用的版本控制工具。

20220525103005

  • 采用“实用为上”的原则,借用了已有的针对单一文件的版本管理工具RCS。
  • 采用客户端/服务器架构设计,版本库位于服务器端,实际上就是一个RCS文件容器。
  • 每一个RCS文件以“,v”作为文件名后缀,用于保存对应文件的每一次更改历史。
  • RCS文件中只保留一个版本的完全拷贝,其他历次更改仅将差异存储其中,使得存储变得非常有效率。

同时,cvs有如下缺点:

  • 服务器端松散的RCS文件导致在建立里程碑或分支时效率不高,服务器端文件越多,速度越慢。
  • 分支(branch)和里程碑(tag)不可见,因为它们被分散地记录在服务器端的各个RCS文件中
  • 合并困难重重,因为缺乏对合并的追踪,从而导致重复合并,引发严重冲突
  • 缺乏对原子提交的支持,会导致客户端向服务器端提交不完整的数据。
  • 不能优化存储内容相同但文件名不同的文件,因为在服务器端每个文件都是单独进行差异存储的。
  • 不能对文件和目录的重命名进行版本控制,虽然直接在服务器端修改RCS文件名可以让改名后的文件保存历史,但是这样做实际上会破坏历史。

SVN--集中式版本控制集大成者

Subversion,由于其命令行工具名为svn,因此通常被简称为SVN。SVN由CollabNet公司于2000年资助并开始开发,目的是创建一个更好用的版本控制系统以取代CVS。SVN的前期开发使用CVS做版本控制,到了2001年,SVN已经可以用于自己的版本控制了”。

20220525103944

SVN最具有特色的功能是轻量级拷贝,例如将目录trunk拷贝为branches/vl,x只相当于在db/revs目录中的变更集文件中用特定的语法做了一下标注,无须真正的文件拷贝。SVN使用轻量级拷贝的功能,轻松地解决了CVS存在的里程碑和分支的创建速度慢 又不可见的问题,使用SVN创建里程碑和分支只在眨眼之间。

SVN还有一个创举,就是在工作区跟踪目录下(.svn目录)为当前目录中的每一个文件都保存一份冗余的原始拷贝。这样做的好处是部分命令不再需要网络连接,例如文件修改的差异比较,以及错误更改的回退等。

但是,相对于CVS,SVN在本质上并没有突破,都属于集中式版本控制系统。即一个项目只有唯一的一个版本库与之对应,所有的项目成员都通过网络向该服务器进行提交。这样的设计除了容易出现单点故障以外,在查看日志和提交数据等操作时的延迟,会让基于广域网协同工作的团队抓狂。除了集中式版本控制系统固有的问题外,SVN的里程碑和分支的设计也被证明是一个错误,虽然这个错误的设计使得SVN拥有了快速创建里程碑和分支的能力,但是这个错误的设计也导致了更多问题。

Git--Linux的第二个伟大作品

Linux之父Linus是坚定的CVS反对者,他也同样地反对SVN。这就是为什么在1991-2002这十余年间,Lius宁可以手工修补文件的方式维护代码,也迟迟不愿使用CVS的原因。

2002年至2005年,Linus顶着开源社区精英们口诛笔伐的压力,选择了一个商业版本控制系统BitKeeper作为Linux内核的代码管理工具”。BitKeeper不同于CVS和SVN等集中式版本控制工具,而是一款分布式版本控制工具。

分布式版本控制系统最大的反传统之处在于,可以不需要集中式的版本库,每个人都工作在通过克隆建立的本地版本库中。也就是说每个人都拥有一个完整的版本库,查看提交日志、提交、创建里程碑和分支、合并分支、回退等所有操作都直接在本地完成而不需要网络连接。每个人都是本地版本库的主人,不再有谁能提交谁不能提交的限制,加上多样的协同工作模型(版本库间推送、拉回,以及补丁文件传送等)让开源项目的参与度有爆发式增长。

20220525111734

2005年发生的一件事最终导致了Git的诞生。在2005年4月,Andrew Tridgell(即大名鼎鼎的Samba的作者)试图对BitKeeper进行反向工程,以开发一个能与BitKeeper交互的开源工具。这激怒了BitKeeper软件的所有者BitMover公司,要求收回对Linux社区免费使用BitKeeper的授权。迫不得已,Linus选择了自己开发一个分布式版本控制工具以替代BitKeeper。

大佬就是大佬,看看这时间线

  • 2005年4月3日,开始开发Git。
  • 2005年4月6日,项目发布。
  • 2005年4月7日,Git就可以作为自身的版本控制工具了。
  • 2005年4月18日,发生第一个多分支合并。
  • 2005年4月29日,Git的性能就已经达到了Linus的预期。
  • 2005年6月16日,Linux内核2.6.12发布,那时Git已经在维护Linux核心的源代码了。

Git与svn区别点

  • Git 是分布式的,SVN 不是:这是 Git 和其它非分布式的版本控制系统,例如 SVN,CVS 等,最核心的区别。
  • Git 把内容按元数据方式存储,而 SVN 是按文件:所有的资源控制系统都是把文件的元信息隐藏在一个类似 .svn、.cvs 等的文件夹里。
  • Git 分支和 SVN 的分支不同:分支在 SVN 中一点都不特别,其实它就是版本库中的另外一个目录。
  • Git 没有一个全局的版本号,而 SVN 有:目前为止这是跟 SVN 相比 Git 缺少的最大的一个特征。
  • Git 的内容完整性要优于 SVN:Git 的内容存储使用的是 SHA-1 哈希算法。这能确保代码内容的完整性,确保在遇到磁盘故障和网络问题时降低对版本库的破坏。

Git-工作流程

Git基本概念

20220525112024

  • 工作区:就是你在电脑里能看到的目录。
  • 暂存区:英文叫 stage 或 index。一般存放在 .git 目录下的 index 文件(.git/index)中,所以我们把暂存区有时也叫作索引(index)。
  • 版本库:工作区有一个隐藏目录 .git,这个不算工作区,而是 Git 的版本库。
  • "HEAD" 实际是指向 master 分支的一个"游标"。代表当前分支

初始化流程

git 命令 说明
git init 当前目录简历git仓库
git clone <repo> 当前目录克隆git仓库
git config --glabal 修改配置,参数为user.name XXX user.email xxx@xxx
git config --list 查看配置
git status 显示当前工作区状态,修改:modified 删除:deleted 添加:Untracked

基本工作流

20220525111935

git 命令 说明
git checkout 从暂存区恢复文件到工作区. 会覆盖当前工作区的修改.添加HEAD则会从版本库拉取,同时覆盖掉暂存区和工作区
git clear -df 从暂存区中取消跟踪文件
git add 把工作区的修改提交到暂存区
git commit 从暂存区提交到版本库. --amend不添加新的提交,本次修改附加到上一次提交
git rm -r --cached 取消跟踪文件

远程操作流

20220525113707

  • workspace:工作区
  • staging area:暂存区/缓存区
  • local repository:版本库或本地仓库
  • remote repository:远程仓库
git 命令 说明 举例
git remote 远程仓库操作 git remote add [shortname] [url] git remote rm name
git fetch 从远程获取代码库
git pull 下载远程代码并合并 git pull <远程主机名> <远程分支名>:<本地分支名>
git push 上传远程代码并合并 git push <远程主机名(origin)> <本地分支名(master)>:<远程分支名(main)>

分支操作流

Git 分支实际上是指向更改快照的指针。也就是那个HEAD

有人把 Git 的分支模型称为必杀技特性,而正是因为它,将 Git 从版本控制系统家族里区分出来

graph LR
a0((a0ccc))
a1((a1ccc))
a2((a2ccc))
a3((a3ccc))

b0((b0ccc))
b1((b1ccc))
b2((b2ccc))

c0((c0ccc))
c1((c1ccc))

A>A]
B>B]
C>C]
HEAD[HEAD]

a0-->a1-->a2-->a3
a1-->b0
b0-->b1-->b2
b1-->c0
c0-->c1

A-->a3
B-->b2
C-->c1
HEAD-->C

git的每一次commit都是一个节点,分支则是节点的一个别名.HEAD则是当前分支的别名

HEAD^n表示在当前HEAD的前n个提交

git 命令 说明 举例
git branch 显示分支 -a示包括远程仓库在内的所有分支 -d删除分支
git checkout 分支名 切换分支 -b参数,如果不存在则创建分支
git merge 分支名 合并分支到当前分支
git push 上传远程代码并合并 git push <远程主机名(origin)> <本地分支名(master)>:<远程分支名(main)>
git reset 本质上是修改HEAD指针,回退的节点在log里不再出现 --soft仅仅修改指针 --mixed把暂存区重置 --hard同时把工作区重置
git revert 本质上是产生一个新的commit

git rerset --mixed 就相当于hg里的压缩历史了,压缩当前工作区到指定节点的历史

git revert则比较人性化,如图所示,直接把内容改成了c2前一个节点的状态,diff是相对于c2的diff

20220525132014

git 远程操作

举例 说明
git pull origin master:main 拉取origin的master分支合并到本地的main分支,并关联分支。省略main则合并到本地当前分支
git push origin main:master 推送本地main分支到origin的master分支;省略master则推送到关联分支;省略main,则表示删除origin的master分支
git branch -vv 查看本地与远程分支关联情况
git branch --set-upstream-to = origin/master main 设置origin主机的master分支与本地main分支关联
git pull --allow-unrelated-histories 忽略版本不同造成的影响

删除远程分支和tag

在Git v1.7.0 之后,可以使用这种语法删除远程分支:git push origin --delete <branchName>,删除tag这么用:git push origin --delete tag <tagname>

当你删除远程分支后,发现本地分支还关联着,并且提示丢失。而你又不想把本地分支删除,所有你得切换到丢失关联的分支,然后git branch --unset-upstream

重命名远程分支

在git中重命名远程分支,其实就是先删除远程分支,然后重命名本地分支,再重新提交一个远程分支。以下例子重命名远程主机origin的分支devel为develop

git push --delete origin devel
git branch -m devel develop
git push --set-upstream origin develop

git log 命令

作为最重要的命令,要单独把git log拿出来讲,用于显示提交日志信息

默认不用任何参数的话,git log 会按提交时间列出所有的更新,最近的更新排在最上面。每次更新都有一个 SHA-1 校验和、作者的名字 和 电子邮件地址、提交时间,最后缩进一个段落显示提交说明。

代码审查-展开内容差异: git log -p,用 -2 则仅显示最近的两次更新

增改行数统计 git log --stat

简要复习完毕,一般来说,默认的 git log 命令就是黑咕噜噜的,挺难看。

使用 git log --graph --pretty=format:'%Cred%h%Creset -%C(yellow)%d%Creset %s %Cgreen(%cr) %C(bold blue)<%an>%Creset' --abbrev-commit换色

可以给命令加上别名: git config --global alias.lg "log --color --graph --pretty=format:'%Cred%h%Creset -%C(yellow)%d%Creset %s %Cgreen(%cr) %C(bold blue)<%an>%Creset' --abbrev-commit"

现在你每次在终端输入git lg,就能看到下面漂亮的git log了

git rebase命令

git rebase命令在另一个分支基础之上重新应用,用于把一个分支的修改合并到当前分支。

假设分支如下,当前基于c2节点工作在mywork,但是与此同时,有些人也在”origin“分支上基于c2做了一些修改并且做了提交了,这就意味着”origin“和”mywork“这两个分支各自”前进”了,它们之间”分叉”了。

flowchart RL
    c4(C4)-->c3(C3)-->c2(C2)-->c1(C1)
    c6(C6)-->c5(C5)-->c2
    origin-->c4
    mywork-->c6

这时候,我们想拉去origin新的代码。有两种方式,一种是git merge,这样会多出来一个合并节点,并且清楚地看出我们是从c2来的,如下图所示

flowchart RL
    c7(C7)-->c4(C4)-->c3(C3)-->c2(C2)-->c1(C1)
    c7(C7)-->c6(C6)-->c5(C5)-->c2
    origin-->c4
    mywork-->c7

我们不想这样,我们要改变历史,仿佛我们是从c4来的!于是我们执行git rebase orgin mywork操作,会把你的”mywork“分支里的每个提交(commit)取消掉,并且把它们临时 保存为补丁(patch)(这些补丁放到”.git/rebase“目录中),然后把”mywork“分支更新 到最新的”origin“分支,最后把保存的这些补丁应用到”mywork“分支上。

flowchart RL
    c61(C6')-->c51(C5')-->c4(C4)-->c3(C3)-->c2(C2)-->c1(C1)
    c6(C6)-->c5(C5)-->c2
    origin-->c4
    mywork-->c61

mywork分支更新之后,它会指向这些新创建的提交(commit),而那些老的提交会被丢弃。 如果运行垃圾收集命令(pruning garbage collection), 这些被丢弃的提交就会删除.

同步remote

rebase之后,本地和remote分支不一样了,remote还是变基之前的分支,所以需要使用 git push --force-with-lease 远程库 myword来更新remote

压缩分支

rebase命令默认不会压缩分支,如果你加上-i参数,则可以选择是否压缩分支

#压缩最近六个节点
git rebase -i HEAD~6 HEAD

#压缩sha2到sha1之间所有节点到sha1
git rebase -i sha1 sha2

然后回弹出一个让你配置需要压缩的节点。pic未不压缩,squash为压缩到上一个节点。如果你需要只保留最初始的节点,则从第二行开始,把所有的pick改为squash即可。保存,退出,git就开始处理压缩了,完了弹出一个commit说明文档,作为合并之后的提交说明

pick 7feb7772 Fix image mirroring
pick e8967fb5 Fix image mirroring - attempt 2
pick 2536f52d fixed build for Linux
pick 18e8b09d fix for splines with 32-bit compiler
pick c8e93bd0 added CHANGELOG and README to project file [ci skip]
pick 3c1b37f4 sync libdxfrw (a610710)
pick 5a07ab41 update CHANGELOG
pick 8b279a8a 这是一个 6 个提交的组合。

# 变基 07128e13..8b279a8a 到 07128e13(8 个提交)
#
# 命令:
# p, pick <提交> = 使用提交
# r, reword <提交> = 使用提交,但编辑提交说明
# e, edit <提交> = 使用提交,但停止以便在 shell 中修补提交
# s, squash <提交> = 使用提交,但挤压到前一个提交
# f, fixup [-C | -c] <提交> = 类似于 "squash",但只保留前一个提交
# 的提交说明,除非使用了 -C 参数,此情况下则只
# 保留本提交说明。使用 -c 和 -C 类似,但会打开
# 编辑器修改提交说明
# x, exec <命令> = 使用 shell 运行命令(此行剩余部分)
# b, break = 在此处停止(使用 'git rebase --continue' 继续变基)
# d, drop <提交> = 删除提交
# l, label <label> = 为当前 HEAD 打上标记
# t, reset <label> = 重置 HEAD 到该标记
# m, merge [-C <commit> | -c <commit>] <label> [# <oneline>]
# . 创建一个合并提交,并使用原始的合并提交说明(如果没有指定
# . 原始提交,使用注释部分的 oneline 作为提交说明)。使用
# . -c <提交> 可以编辑提交说明。

git子仓库深入浅出

通俗上的理解, 一个Git仓库下面放了多个其他的Git仓库,其他的Git仓库就是我们父级仓库的子仓库。我们经常碰到这种情况,需要多个父级仓库都依赖同一个子仓库,但是子仓库自身不单独进行修改,而是跟随父级项目进行更新发布,其他依赖子仓库的项目只负责拉取更新即可。

在正式开始介绍git的子仓库之前,我们要提前认识到一点,在刚开始使用Git子仓库的时候,如果不是很了解底层原理,很可能会导致使用子仓库出现云里雾里的现象,搞不清楚是父级仓库先提交,还是子仓库先提交,所以在本教程中,我们会先介绍子仓库的两种使用方式,然后携带一些子仓库的Git底层的分析,让大家对子仓库有一个更加全面的认识。

Git有两种子仓库使用方案git submodule,git subtree我们按照顺序分别演示这两种子仓库的使用方式,方便大家深入理解两种子仓库的使用方式

git submodule

Git子模块允许我们将一个或者多个Git仓库作为另一个Git仓库的子目录,它能让你将另一个仓库克隆到自己的项目中,同时还保持提交的独立。

git submodule add 子仓库路径默认情况下,子模块会被添加到项目的子模块同名的目录下,如果想放到其他目录. 在add命令的结尾跟上放置目录的相对路径即可

同时还要注意的是,Git为我们创建了一个.gitmodules文件,这个配置文件中保存了子仓库项目的URL和在主仓库目录下的映射关系

执行git status发现有了新的文件.我们对main仓库进行一次提交,这就把添加子仓库的操作记录进了本地仓库

克隆包含子仓库的仓库

比如main项目包含了lib子项目,当我们正常克隆main项目的时候,我们会发现,main仓库中虽然包含lib文件夹,但是里面并不包含任何内容,仿佛就是一个空文件夹.此时你需要运行git submodule的另外两个命令,不需要担心,submodule的命令不会太多。

首先执行git submodule init用来初始化本地配置文件,也就是向.git/config文件中写入了子模块的信息。git submodule update则是从子仓库中抓取所有的数据找到父级仓库对应的那次子仓库的提交id并且检出到父项目的目录中。

现在我们查看main仓库下的目录结构,会发现和我们之前的提交的结构保持一致了,我们成功的拉取到了父级仓库和相关依赖子仓库的代码。

上述命令着实有些麻烦,有没有简单一些的命令能够直接拉取整个仓库的代码的方式呢? 答案是有的,我们使用git clone --recursive,Git会自动帮我们递归去拉取我们所有的父仓库和子仓库的相关内容。

在主仓库上协同开发

首先进入到子仓库目录,我们也发现当前不在lib的master分支上,而是在一个游离分支上面,这个游离分支的hash正是lib仓库的master分支的hash值,这正是git submodule为我们所做的, Git不关心我们开发的分支,而只是去拉取子仓库对应的commit提交。

首先在子仓库完成正常的提交并且push.子仓库提交结束后,我们回到main仓库的主目录下,status提示有一个新的提交,我们正常提交并push.对git submodule使用下来发现, submodule本身就是一个大的Git仓库下包含了多个子的Git仓库,我们修改之后,首先对每个子仓库进行了提交,然后父级仓库就会记录下每个子仓库的提交,然后正常提交父级仓库即可

拉取也是同样的过程,如果是在子仓库的分支上开发,也是先拉取子仓库,随后拉取父级仓库的更新.如果觉得对每个子仓库进行提交繁琐的话,git sumodule foreach git pull就可以解决你这个烦恼

git submodule status显示子仓库状态git submodule sync子仓库url变化后同步设置

子模块的使用

git submodule add -b 分支 <url> <path>

git submodule init
git submodule update

git submodule update --init --recursive

rm -rf 子模块目录
vi .gitmodules
vi .git/config
rm .git/module/*
git rm --cached 子模块名称

git submudule原理分析

我们知道Git底层大致依赖了四种对象,构成了Git对于文件内容追踪的基础

  • blob: 二进制大文件,可以通俗理解为对文件的修改
  • tree: 记录了blob对象和其他tree对象的修改,通俗理解为目录
  • commit: 提交对象,记录了本次提交的tree对象和父类的commit对象以及我们的提交信息
  • tag: 我们对当前提交记录版本的对象

Git在处理submodule引用的时候,并不会去扫描子仓库下的文件的变化,而是取子仓库当前的HEAD指向的commit的hash值,当我们对子仓库进行了更改后,Git获取到子模块的commit值发生变化,从而记录了这个Git指针的变化。

在暂存区所以我们才发现了new commits这种提示语,Git并不关心子模块的文件如何变化,我只需要在当前提交中记录子模块的commit的hash值即可,之后我们从父级仓库拉取子仓库的时候,Git拉取了本次提交记录中的子模块的hash值对应的提交,就还原了我们的整个仓库的代码。

  1. 当子模块有提交的时候,没有push到远程仓库, 父级引用子模块的commit更新,并提交到远程仓库, 当别人拉取代码的时候就会报出子模块的commit不存在 fatal: reference isn’t a tree。
  2. 如果你仅仅引用了别人的子模块的游离分支,然后在主仓库修改了子仓库的代码,之后使用git submodule update拉取了最新代码,那么你在子仓库游离分支做出的修改会被覆盖掉。
  3. 我们假设你一开始在主仓库并没有采用子模块的开发方式,而是在另外的开发分支使用了子仓库,那么当你从开发分支切回到没有采用子模块的分支的时候,子模块的目录并不会被Git自动删除,而是需要你手动的删除了 。

git subtree

git subtree则是由第三方开发者贡献的contrib script,Git本身并不提供git subtree命令,contrib中包含一些实验性的第三方工具,由各自的作者进行维护。

我们认识到git subtree不是Git原生支持的命令,而是第三方开发者通过Git的底层命令写出的一个高层次脚本,所以它是可以由基础的Git命令来实现的

所以我们就不讲了.对于这种,一个文件存两份的事情,要坚决杜绝!

  • 虽然 Git 提供的子模块功能已足够方便好用,但仍请在为主仓库项目添加子模块之前确保这是非常必要的。毕竟有很多编程语言(如 Go)或其他依赖管理工具(如 Ruby’s rubygems, Node.js’ npm, or Cocoa’s CocoaPods and Carthage)可以更好的 handle 类似的功能
  • 主仓库项目的合作者并不会自动地看到子模块仓库的更新通知的。所以,更新子模块后一定要记得提醒一下主仓库项目的合作者 git submodule update。