简明 Git

2022-01-27

Git 本质上是一个备份工具,不过它使用了一个 diff 算法为每次改变都做了一个节点的备份,很多关于 git 的教程实际上只是一个 cheatsheet, 并没有真正讲明白 git 的内在逻辑。一般来说,使用 git 进行版本控制的工作项目至少存在 3 份工作环境,分别是本地 , 缓存 cache, 和远程仓库 remote.

本地

本地代码就是项目目前的状态,项目的编译运行等都只与本地代码相关,下面这些命令可为本地项目初始化 git 仓库:

# 初始化项目,在本地生成 .git 目录
git init

# 设置 git 全局基本信息,保存到 ~/.gitconfig 文件中
git config --global "Your Name"
git config --global "Email Address"

# 为当前仓库设置信息,保存到 .git/config 中
git config user.name "Your Name"
git config user.email "Email Address"

初始化生成的 .git 目录下存放着所有关于本项目的信息

.git
├── HEAD                  指向当前所在分支的引用。
├── ORIG_HEAD             HEAD 指针的前一个指向状态
├── FETCH_HEAD            指向已经从远程仓库取下来的无名分支
├── COMMIT_EDITMSG        最近一次提交的 commit 信息
├── config                当前仓库的配置文件
├── branches              分支
├── description           项目描述
├── hooks                 存放一些 shell 脚本
├── index                 存储暂存区(也称为索引)的内容。
├── info                  存储一些额外信息,例如排除文件列表
├── logs                  保存所有更新的引用记录
├── objects               所有的 Git 对象
└── refs                  存储分支和标签的引用。

可以修改 config 文件达到某些效果,例如:

# 用户信息
[user]
	email = my_email@example.com
	name = sheffey
# 提交时自动转换换行符
[core]
	autocrlf = input
# 配置命令别名
[alias]
    tree = log --graph --decorate --pretty=oneline --abbrev-commit --all
# 默认初始分支名
[init]
	defaultBranch = main
# 设置代理
[http]
	proxy = http://127.0.0.1:7890
# 使用 git 协议而不是 https
[url "git@github.com:"]
    insteadOf = https://github.com/

git 默认对本地所有文件(而非目录)都做跟踪,在 .gitiginore 中添加你想要排除的文件或文件夹,ignore 文件的语法规则如下: .gitignore 的忽略规则语法如下:

  • #:以 # 开头的语句是注释;
  • *:星号作为统配符,可代表任意字符
  • []:方括号表示包含单个字符的匹配列表;
  • :以叹号表示不忽略 (跟踪)匹配到的文件或目录,即指定跟踪此文件。
  • /:用 / 表示目录;

.gitignore 文件中靠前的规则强于靠后的规则

/ 结束的模式只匹配文件夹以及在该文件夹路径下的内容,但是不匹配该文件;

/ 开始的模式匹配项目的根目录;

如果一个模式不包含斜杠,则它匹配相对于当前 .gitignore 文件路径的内容,如果该模式不在 .gitignore 文件中,则相对于项目根目录。

缓存 Cache

Cache 是每次提交的修改的本地备份。一些基本使用方法如下:

# 指定文件加入暂存区,使用 . 添加所有修改
git add <file>

# 提交所有已 track 的文件至 staging area
git add -u

# 提交修改,使用 -am 可以直接提交所有 track 的修改
git commit -m "descriptions"

# 修改提交信息
git commit --amend -m "<new commit message>"

# 修改最近提交用户名和邮箱
git commit --amend --author "user_name <user_email>" 

注意: 只要文件加入了缓存,修改 .ignore 文件是无法排除这个文件的,你需要使用 git rm --cached <file> 来去除对文件的跟踪。

git rm <file> 会默认删除 git 缓存和工作目录中的文件,而 --cached 参数表示仅删除 git 缓存中的文件,而不删除工作目录中的文件,也就是说 git rm <file> 相当于 git rm --cached <file>rm <file>.

一旦进行了提交,git 会计算出所跟踪的文件基于前一次文件做了哪些修改,并将这些修改记录存入缓存,作为历史树的一个节点。 使用 git log 参看提交记录,你可以看到 HEAD 指向你最近的一次提交。

# 仅显示短 hash 和 commit 信息
git log --oneline

# 启用格式化输出节点树
git log --graph --pretty=format:'%Cred%h%Creset -%C(yellow)%d%Creset %s %Cgreen%ai(%cr) %C(bold blue)<%an>%Creset'

本地回退

针对本地文件的修改,可以使用本地缓存的修改达到一个回退的效果:

# 回退到最近一次提交,即舍弃所有未提交的修改
git checkout -- <file>
git restore <file>
git reset --hard HEAD

上面的 --hard 表示无视本地的修改,将项目重置为 HEAD 指向的节点,相对应的 --soft 表示保留本地的修改,也就是说 git reset --soft HEAD 不会对项目产生任何影响。

这里的节点有两种指定的方法,一是直接使用 hash, 或者使用 HEAD 来表示相对 HEAD 的某个节点。

# 使用 hash 或简写表示需要回退到哪个节点
git reset --hard hash

# 相对于 HEAD 的回退
git reset --hard HEAD^      回退到上一个提交
git reset --hard HEAD~5     回退到此前第 5 个提交

HEAD^nHEAD~n 有什么区别?HEAD^ 用于有多个父提交的情况,而 HEAD~ 用于线性的节点树的情况。

一个常见的关于回退提交的例子是,你可以通过先 git reset --soft HEAD^n 回退到前 n 个提交节点,然后 git add . 再提交以合并这几次提交,不过如果你只是想要附加一个修改到上次提交,一个更优雅的方式是直接添加当前修改后使用 git commit --amend 附加修改。

revert 是另一种 “回退” 的方式,不过它是通过产生一个新的提交来达到回滚的目的。也就是说,你的所有提交记录,包括你想覆盖掉的,仍然会被记录在仓库的时间线中。

# 新建一个节点,其改动为回退到 HEAD 的上一个节点
git revert HEAD

# 如果你需要撤回多个提交,那么你需要把它们都写上
git revert <hash1> <hash2>

分支

git 缓存带来的另一个好处是分支,即在某个节点的基础上另开一条记录,这对项目开发很有帮助,比如一般的项目都会有至少两个分支,即一个稳定的分发版分支,即主分支,和其他功能开发分支。

下面是一些基本的分支操作:

# 查看已有分支(* 表示当前分支)
git branch

# 不带参数表示新建分支,参数 m 重命名,参数 d 删除
git branch <branch name>

# 切换当前分支
git checkout <branch name>

# 创建并切换至<branch name>分支
git checkout -b <branch name>

# 基于远程仓库<origin>下的<master>分支创建一个新的分支
git checkout origin/master -b <branch name> 

一个常见的需求是当 dev 分支功能开发完成,这时需要将这个分支合并到主分支 main 上,Git 有多种方式可以实现这个合并需求。

使用 merge 方式会产生一条合并的提交记录,同时 dev 分支上的新的提交也会被自动检索出来添加到 main 分支上,git log --graph --oneline 可以看到 merge 方式能反映出每个 commit 来自哪个分支,但会破坏提交的线性记录。

*   d2aa62f Merge branch 'dev' into main
|\  
| * 470978b dev branch commit
* | 708f69e main branch commit
* | 45958b6 main branch commit
* | 1438bdd main branch commit
|/  
* a69152b main branch commit

假如 dev 分支上的提交记录有很多,并且改动内容一致时可以考虑将这些分支"压缩"为一个 commit, 只需要添加选项 squash 即可:

git merge --squash dev

假设你只想将 dev 分支上某些特定的 commit 合并到 main 分支上,而不是用 merge 一股脑地添加到 main 分支上,使用 cherry-pick 命令即可:

git cherry-pick <hash1> <hash2>

注意这里使用的是指定 commit 的 hash 值,而不是分支名,所以你可能需要先查看并记下 dev 分支目标 commit 的 hash 值,再在 main 分支上使用 cherry-pick 命令。

❯ git log -b dev --oneline
<hash1>
<hash2>
...

❯ git cherry-pick <hash1> <hash2>

还有一种常见的合并方式是 rebase, 中文译作 “变基”, 因为它能修改当前 main 分支上现有的 commit 记录。merge 合并方式默认将 dev 分支"排"在当前 commit 之后,而rebase的做法是默认将dev上的 commit 记录"插队"到 main 分支上,这样做会导致自 main 和 dev 的共同节点之后所有的提交节点的哈希值都发生改变,一般应该避免在公用的 main 分支使用rebase操作,rebase 操作更多应用在 dev 分支上,因为在 dev 分支上使用 rebase 可以保证你的修改是在最新的 main 分支节点上进行的,以避免 dev 分支合并到 main 分支时发生冲突,换言之就是将冲突在 dev 分支上提前解决。

事实上,rebase 命令的本意是修改现有的提交历史,假设你需要对最近 5 个 commit 做出修改,可以使用 git rebase -i HEAD~5 打开一个可编辑文本:

pick 558772c 1st commit
pick e901709 2nd commit
pick d88d2f6 3rd commit
pick 8272242 4th commit
pick 3d7d919 5th commit

文本中的每一行都是由旧到新的历史提交,直接编辑或重排这些记录即可修改这些提交历史,这个文本下面有相关编辑命令的解释:

# Commands:
# p, pick <commit> = 使用这个 commit
# r, reword <commit> = 使用这个 commit, 但是编辑提交信息
# e, edit <commit> = 使用这个 commit, 但是编辑提交内容
# s, squash <commit> = 使用这个 commit, 但合并到上一个 commit
# f, fixup [-C | -c] <commit> = 类似 squash 但略有不同
# x, exec <command> = 用 shell 执行指定命令
# b, break = 在此处暂停
# d, drop <commit> = 删除该 commit
# l, label <label> = 为该 commit 添加一个标签
# t, reset <label> = 重置 commit 到指定节点
# m, merge [-C <commit> | -c <commit>] <label> [# <oneline>] 创建一个 merge 合并

需要记住的是,Git 的每个节点都必须基于其父节点来记录修改内容,所以任何修改都会使这个父节点之后的所有子节点发生变化,尽管你可能并没有直接对后面的节点做修改。rebase 命令会修改更"基本"的父节点,所以修改后的提交树不再和原本同步,如果你想要将修改同步到其他仓库的话,就只能使用强制提交了,这通常在多人合作的 main 分支上是不被允许的,但你可以在自己的 dev 分支上自由使用这个命令。

冲突

合并分支时出现冲突是很常见的情况,冲突代表两个分支对同一行代码做出了不同的修改,这时需要你来决定如何处理这些冲突。可以使用 git status 查看现在有哪些文件出现了冲突,再使用 vim 等编辑器打开冲突的文件找到冲突的位置并修改为你的最终版本。

<<<<<<< HEAD
modified this line
=======
modified this line again
>>>>>>> ef34b8e (commit it agian)

注意出现冲突后的工作目录是一个中间状态,其并不属于任何分支

❯ git branch
* (no branch, rebasing new)
  main
  new

所以你手动解决了冲突后需要先 git add 将文件暂存,再使用 continue 继续之前的合并操作。

❯ git add <conflict_file>

❯ git rebase --continue
[detached HEAD 8272242] solve conflict
 1 file changed, 1 insertion(+), 1 deletion(-)
Successfully rebased and updated refs/heads/new.

远程 Remote

现如今 GitHub 和 GitLab 等代码托管平台已不仅仅是一个代码托管平台了,更是一个技术合作与交流的社区,而 GitHub 更是成为了现代互联网基石的一部分。

使用 remote add 添加一个远程仓库 origin, 如果使用的 git 协议的话会是:

git remote add origin git@github.com:username/reponame.git

大多数服务默认使用 https 协议,需要用户手动输入用户名和密码,使用 git 协议无需输入,但需要先将 ssh 等密钥上传至服务器记录。

如果项目文件都在远程仓库,不需要在本地创建,则可以使用 git clone 命令从服务器拉取所有文件。

git clone https://github.com/sheffey/repo.git

注意这样的克隆方式会将所有的提交记录也拉取到本地,如果你拉取的项目十分庞大,比如有上千次提交,你可以添加一个选项 --depth=1,表示只拉取最新的代码,不需要其他历史提交。

下面是一些常用的远程仓库交互:

# 指定本地分支 main 与远程分支 origin/main 建立链接
git branch -u origin/main main

# 查看本地分支与远程分支的链接关系
git branch -vv

# push 推送到远程仓库,使用 -u 参数指定与远程分支建立联系
git push -u origin main

# fetch 获取远程代码改动到缓存
git fetch origin main

# merge 尝试将拉取的修改合并到本地
git merge origin main

# fetch 和 merge 可以合并为一条 pull 命令
git pull origin main

实际上拉取代码时的 fetch 操作会将远程仓库的提交修改拉取到本地的一个无名分支(FETCH_HEAD)上,注意此时你电脑上的文件并没有发生变化,只有将这个无名分支与本地分支合并(merge)后,你的文件才同步到了远程仓库的版本,所以一般拉取代码就是先 fetchmerge , 这两步可以简化为 pull , 也就是说 pull 实际上是 fetchmerge 两个操作的组合。

如果 merge 时出现了冲突,可以使用 git stash 暂存当前的变化,先同步远程仓库再做处理。 git stash 会将所有改动先保存到缓存,相当于 git add . 随后使用 git stash pop 即可恢复。

子模块 submodule

使用 submodules 来管理主仓库下的其他子项目能保证子模块的相对完整性,使本地子模块更方便地同远程仓库同步,同时也不会给主项目带来更多不必要的操作。 假设我们需要添加子模块 https://github.com/sheffeyg/subrepo.git 到 sub 文件夹中,可以使用这样的命令:

git submodules add https://github.com/username/subrepo.git sub

父项目文件夹中会出现一个。gitmodules 文件,里面记录了子模块的信息

[submodule "sub"]
   path = sub
   url = https://github.com/sheffeyg/subrepo.git

如果将这个项目推送到 GitHub 上,浏览子模块文件夹就会发现远程仓库存放的是对应仓库的链接,而非冗余的文件。

带有子模块的父项目被克隆下来后,使用 git submodules 查看所包含的子模块

$ git submodule
   -33578324c2b5eef0f37cb385550351d90e82265a sub (v0.2.13-332-g33578324)

可以看到子项目前有一个 - 号,对应的 sub 文件夹也是空的,说明这个子模块尚未被检入。单独操作子模块

# 初始化子模块,拉取代码
git submodule update --init

# 从远程更新子模块
git submodule update --remote

或者使用递归参数直接连同父项目一起处理

# 递归克隆整个项目,同时更新子模块
git clone https://github.com/sheffeyg/myrepo.git <local_dir> --recursive

# 递归初始化
git submodule update --init --recursive

# 递归同步远程仓库
git submodule update --recursive --remote

# 递归更新远程子模块
git pull --recurse-submodules

需要特别注意主项目的 Git 并不会自动管理子项目的 Git, 所以你应该先提交子项目再进行父项目的提交.

删除子模块需要手动删除相关的文件,以删除子模块 sub 为例

# 逆初始化模块,其中<dir_sub>为模块目录,执行后可发现模块目录被清空
git submodule deinit <dir_sub> 

# 删除。gitmodules 中记录的模块信息(--cached 选项清除。git/modules 中的缓存)
git rm --cached <dir_sub> 

# 提交更改到代码库,可观察到'.gitmodules'内容发生变更
git commit -am "Remove a submodule." 

子仓库 subtree

git submodule 不同的是,git subtree 允许你将一个仓库作为子目录嵌入到另一个仓库中,并保持两个仓库的独立提交历史。

1. 添加子项目:

git subtree add --prefix=<子项目目录> <子项目仓库地址> <分支>

2. 更新子项目:

git subtree pull --prefix=<子项目目录> <子项目仓库地址> <分支>

这将从子项目的指定分支拉取最新的更改,并将其合并到主项目中。

3. 推送更改到子项目:

cd modules/sub-project
# 进行修改。..
git add .
git commit -m "Update sub-project"

cd ../..
git subtree push --prefix=<子项目目录> <子项目仓库地址> <分支>

这会将主项目中对子项目的修改推送到子项目的指定分支。

相较于 submodule 使用 subtree 的优势在于能保持子项目的独立提交历史,而且主仓库能自动管理子仓库的变动,所以能更加方便地更新和推送子项目的更改。

[ Code ]