Git 学习笔记(CheatSheet)(一)

2020-12-19  本文已影响0人  Whyn

[TOC]

Git 思维导图

简介

当我们进行项目开发时,都会使用『版本控制系统(Version Control Systems,VCS)』托管项目源码,版本控制系统记录了项目源码的历史迭代过程,可以很方便让我们比对不同版本之间源码差异,以及在任意时刻回滚到某一个历史版本中...对项目的持续迭代过程非常有帮助。

传统的版本控制多使用『集中化版本控制系统(Centralized Version Control Systems,CVCS)』,这些系统将源码存放于一个单一的中央服务器中,方便进行集中化管理。协同工作的开发人员通过网络连接到这台服务器,下载最新源码或提交更新。

CVCS 优点是协同流程简单,项目管理方便。但缺点是当存在网络故障或服务器宕机时,无法开展协同工作且数据存在丢失风险。
因此,『分布式版本控制系统(Distributed Version Control System,DVCS)』便应运而生了。

传统的集中化版本控制系统都要求存在一个公共的中央服务器,用以存储项目源码历史记录,而分布式系统不存中央结点概念,或者说每个结点都是中央结点,都存储了同一份源码的完整历史记录,因此任何一个结点数据丢失,不会影响到其他结点数据。

当前最流行的分布式版本控制器应当就是 Git 了,它的最初版本是由 Linux 之父 Linus Torvalds 进行开发的。
:DVCS 解决了 CVCS 数据不安全问题,但由于不区分主从结点,每个结点都是高度自治的,因此在协同开发上不是很方便。这其实也是分布式系统存在的共同问题,而要解决这个问题,其实也不难,只需提供一个端对端发现机制即可,这样分布式网络中的任意一个结点就可通过这个发现机制搜索到目标仓库结点,从而可以开展协同工作。对于 Git 而言,当前使用最多的第三方端对端发现机制就是 Github,Github 提供了一个仓库托管、发现和管理平台,其他结点可从该平台上搜索找到感兴趣的目标仓库,进行协同开发。

本篇博文主要介绍使用 Git 进行版本控制相关内容,对相关场景下的 Git 使用进行简介,尽量涉及常用的 Git 命令,可将本篇博文作为 Git 使用的一个速查表(CheatSheet)。

在阅读本篇博文前,强烈建议先阅读下本人写的另一篇介绍 Git 底层实现原理的博文:Git 内部实现原理剖析,了解下 Git 的底层实现原理可以让我们对所使用的命令有更充分的认识,避免死记命令,才能更加游刃有余。

简单来讲,Git 的本质是一个『内容寻址文件系统(Content-Addressable Filesystem)』,它只关注被追踪文件的内容。当暂存一个文件时,Git 会首先对该文件内容以一定的格式进行 SHA-1 计算,得到文件内容的一个数字摘要,然后将文件内容存储到本地对象数据库中。数字摘要从理论上说,几乎唯一的标识了一份内容,因此,依据该数据摘要,我们就可以获取得到其对应的文件内容,所以 Git 的核心部分其实就是一个简单的键值对数据库(Key-Value Data Store)。
这其实也是 Git 与其他版本控制系统最主要的差别,其他的版本控制系统多采用基于差异(delta-based)的机制实现版本控制,而 Git 是以 全量快照 方式保存文件内容。换言之,集中化版本控制系统是基于增量式文件系统,而 Git 采用的是自己实现的一套全量式存储文件系统。

安装

帮助文档

Git 命令的所有文档可查看:在线文档 - Reference

Git 的官方在线文档在中国可能无法直接访问,此时其实可以直接在终端查看帮助文档,如下所示:

$ man git <verb>    # 法一
$ git help <verb>   # 法二
$ git <verb> --help # 法三
$ git <verb> -h     # 法四

上述前三种方法效果一样,任选其一即可,比如,如果我们想要查看git config的用法,就可以使用如下命令:

$ git help config

该命令会列出git config的详细用法。

以上方法查询得到的命令文档非常详细,而如果想要查看更简洁紧凑的命令用法,可以直接使用-h参数,比如:

$ git config -h

相对来说,-h会更加直观且易读,推荐使用该种查询。

配置

Git 中配置主要分为全局配置和项目本地配置,可通过命令git config设置和查看配置选项,其格式如下:

git config <options>

配置作用域

Git 中存在三种配置作用域:

$ git config --system --edit
$ git config --global --edit
$ git config --local --edit

:系统配置、全局配置 和 本地仓库配置会依序进行加载,因此后面的配置会覆盖前面相同的配置。

:可通过以下命令查看各个配置选项内容及其所在的配置文件:

$ git config --list --show-origin
file:/home/whyn/.gitconfig      user.name=Why8n
file:.git/config        core.repositoryformatversion=0
...

基础配置

使用 Git 的第一步就是进行一些基础配置,通常会先全局配置用户名和邮箱:

配置查询

Git 中主要的查询有如下几种:

删除配置

Git 提供了多种方法对配置选项进行删除,这里只介绍一种:

服务器配置 SSH 公钥

进行版本控制时,通常将源码存储到一个中央服务器中,方便开展协同工作。这里我们使用 Github 平台作为远程中央服务器,存储我们的项目代码。
Github 支持 SSH 协议连接,为了方便使用,可以本地生成一个 SSH 公钥添加到对应的 Github 账户中,这样后续使用时就无需手动输入用户名和密码进行验证,具体步骤如下所示:

  1. 本地生成 SSH 公钥:

    $ ssh-keygen -t ed25519 -C "whyncai@gmail.com"
    Generating public/private ed25519 key pair.
    Enter file in which to save the key (/home/whyn/.ssh/id_ed25519): # 存储路径
    Enter passphrase (empty for no passphrase): # 密钥口令,留空即可。
    Enter same passphrase again:
    Your identification has been saved in /home/whyn/.ssh/id_ed25519.
    Your public key has been saved in /home/whyn/.ssh/id_ed25519.pub.
    

    :生成过程中会要求输入两次密钥口令,我们将其留空即可,这样后续使用密钥时,无需键入口令。

    此时,~/.ssh目录会生成两个新文件,其中,.pub文件为 SSH 公钥,另一个为 SSH 私钥文件。

  2. 拷贝公钥内容:

    $ xclip -selection clipboard < ~/.ssh/id_ed25519.pub
    
  3. 将公钥内容添加到 Github 账号上,步骤如下:
    1). 打开账号设置界面:


    Settings

    2). 左侧用户设置栏选择:SSH and GPS kyes

    SSH and GPG keys

    3). 点击:New SSH key,弹出配置界面:Title域填入自定义标题,Key粘贴公钥内容,最后点击 Add SSH key 即可。

  4. 以上,就已成功添加本机 SSH 公钥到 Github 账户上:


    SSH public key
  5. 此时可以测试使用 SSH 连接到 Github,看下配置是否成功:

    $ ssh -T git@github.com
    # 测试连接成功
    Hi Why8n! You've successfully authenticated, but GitHub does not provide shell access.
    
  6. 最后还有一步,将远程仓库路径设置为 SSH 协议:

    # 更换为 SSH 协议地址
    $ git remote set-url origin git@github.com:USERNAME/REPOSITORY.git
    
    # 查看远程仓库信息
    $ git remote -v
    > origin  git@github.com:USERNAME/REPOSITORY.git (fetch)
    > origin  git@github.com:USERNAME/REPOSITORY.git (push)
    

更多 Github 配置 SSH 信息,请参考:Connecting to GitHub with SSH

基础操作

使用 Git 对本地仓库进行管理,主要涉及如下命令:

git init

本地对项目进行版本控制,第一步就是初始化 Git 版本控制,使用的是git init命令,其具体格式如下:

git init [-q | --quiet] [--bare] [--template=<template_directory>]
      [--separate-git-dir <git dir>] [--object-format=<format>]
      [-b <branch-name> | --initial-branch=<branch-name>]
      [--shared[=<permissions>]] [directory]

其中:

git add

当要将文件纳入版本控制时,首先需要将该文件添加到暂存区中,这样 Git 就会追踪该文件,提交时就会记录该文件当前历史状态。

追踪文件使用命令git add,其语法如下所示:

git add [<options>] [--] <pathspec>...

:如果想一次性添加所有文件到暂存区中,可使用命令:git add .

git commit

如果想保存当前工作区快照,此时可将工作区所有修改进行暂存,然后提交即可。

提交使用的命令为git commit,其语法如下所示:

git commit [<options>] [--] <pathspec>...

git status

我们知道,Git 中存在三个分区:工作区、暂存区和版本库。其中:

由于被追踪文件存在与这三个分区中,因此同一时间,这三个区的同一文件内容可能相同,也可能存在差别。简单来说,依照文件在不同分区之间的差异,主要存在三种状态:已修改(modified)已暂存(staged)已提交(commited)

可通过命令git status来查看文件在这三个区的状态,其语法如下所示:

git status [<options>…] [--] [<pathspec>…]

git status输出的信息相对比较繁杂,此时可使用选项-s, --short来输出更加紧凑的信息:

$ git status -s
 M README
MM Rakefile
A  lib/git.rb
M  lib/simplegit.rb
?? LICENSE.txt

git log

Git 维护了所有的历史提交信息,可以通过命令git log查看所有提交日志,可以通过git show查看某个提交的具体内容。

git log的语法如下所示:

git log [<options>] [<revision range>] [[--] <path>…]

下面介绍该命令常用的几个选项:

git rm

Git 中对文件的删除本意是从暂存区中删除该文件,表示不再追踪该文件。

Git 中对删除文件使用命令git rm,其语法如下所示:

git rm [<options>] [--] <file>...

git rm主要有如下两种删除效果:

最后,如果只想删除工作区文件,而不删除暂存区,则使用/bin/rm

提交规范(Conventional Commits specification)

一个结构分明,描述清晰的提交信息对于快速查看、日志过滤等操作是十分有益的,尤其是对于团队项目,一个统一风格的提交信息结构有助于团队成员理解项目变更,防止杂乱信息影响开发进度...具备稳定结构的日志提交,还方便于使用工具自动生成项目变更历史(CHANGELOG.MD)。

当前使用最广泛的提交信息规范为:约定式提交规范(Conventional Commits),它主要受到 Angular规范 的启发。

Conventional Commits 规定,提交信息包含三部分内容:Header、Body 和 Footer,其具体结构如下所示:

<type>[(scope)][!]: <description> # Header
                                  # 空行
[body]                            # Body
                                  # 空行
[footer(s)]                       # Footer

从上述结构可以知道,除了<type>: <description>必须给出外,其他结构都是可选的。
并且任意一行长度不能超过 100 个字符,方便阅读与集成到其他 Git 工具中。

下面介绍各部分具体内容:

Header

Header 总共有三个字段:

Body

Body 结构是对本次提交的详细描述

Body 结构的规范如下:

Footer

Footer 主要是针对本次提交进行一些备注。主要有两种情况需要进行备注:

Footer 结构的规范如下:

最后,可以通过使用第三方工具(比如:Commitizen)来确保每次提交信息符合规范。
如果要生成项目变更历史,确保在提交符合规范前提下,可以通过使用第三方工具(比如:conventional-changelog)来自动生成 Change log。

更多提交规范详情,可参考如下文章:

忽略文件

可以通过配置.gitignore来忽略某些文件或文件夹的追踪。

通常只需在根目录配置一个.gitignore文件,该文件规则就可以递归生效到整个仓库中。但是也可以为某些子目录添加.gitignore文件,此时该忽略文件只作用于当前子目录中。

.gitignore的配置规则如下所示:

一个.gitignore文件例子如下:

# 忽略所有的 .a 文件
*.a

# 但跟踪所有的 lib.a,即便你在前面忽略了 .a 文件
!lib.a

# 只忽略当前目录下的 TODO 文件,而不忽略 subdir/TODO
/TODO

# 忽略任何目录下名为 build 的文件夹
build/

# 忽略 doc/notes.txt,但不忽略 doc/server/arch.txt
doc/*.txt

# 忽略 doc/ 目录及其所有子目录下的 .pdf 文件
doc/**/*.pdf

差异比较

如果想查看同一文件在不同区之间的内容差异,或者查看版本之间的差异,则可以使用git diff命令,其语法如下所示:

git diff [<options>] [<commit>] [--] [<path>...]
git diff [<options>] --cached [--merge-base] [<commit>] [--] [<path>...]
git diff [<options>] [--merge-base] <commit> [<commit>...] <commit> [--] [<path>...]
git diff [<options>] <commit>...<commit> [--] [<path>...]
git diff [<options>] <blob> <blob>
git diff [<options>] --no-index [--] <path> <path>

主要的差异比较有如下几种:

最后,如果觉得git diff输出的比对效果不是很直观,也可以使用命令git difftool来唤起系统默认的比对工具(比如 vimdiff)进行差异比对:

$ git difftool HEAD~1 HEAD
$ git difftool HEAD~1 HEAD 1.txt

回滚操作

Git 操作中,会经常遇到需要进行回滚或撤销动作,常见的回滚操作如下所示:

撤销工作区文件修改

撤销工作区文件修改,恢复到暂存区文件状态,可以使用如下命令:

git restore [--worktree] <file>

工作区文件回退到指定版本

如果想将工作区文件回退到某个提交版本,可以使用如下命令:

git restore [-W | --worktree] {-s | --source}[=]<tree-ish> <file>

git restore默认回退工作区修改,因此可忽略选项-W, --worktree

比如,将文件1.txt重置到当前分支最新提交的版本:

$ git restore --source=HEAD 1.txt

撤销暂存区文件修改

如果对已修改的文件进行了暂存,此时想撤销这个暂存,恢复到版本库最新提交(即HEAD)的文件状态,可以使用如下命令:

git restore {-S | --staged} <file>

暂存区文件回退到指定版本

如果想将暂存区中文件重置到某个版本,可使用如下命令:

git restore {-S | --staged} {-s | --source}[=]<tree-ish> <file>

比如,将文件1.txt重置到第二个最新版本:

$ git restore --staged --source HEAD~1 1.txt

同时回退工作区和暂存区文件到指定版本

结合上面所讲内容,就可以实现同时回退工作区和暂存区文件到某个指定版本,其命令如下:

git restore --worktree --staged --source=<tree-ish> <file>

重置最后一次提交

当一次提交完成后,此时可能出现某些原因导致你想撤销这次提交,则此时可通过命令git commit --amend使用新提交重置上一次提交。

$ git commit -m '3'
$ git add .
# 此时 4 会替换掉 3
$ git commit --amend -m '4'

:如果不需要修改提交信息,可添加--no-edit选项:

$ git commit --amend --no-edit

git commit --amend的效果就相当于使用一个新提交替换掉当前最新的提交(即HEAD),使得原先提交不会出现在历史日志中。其图示如下所示:

git commit --amend

git reset

git reset的主要作用是移动HEAD指针到指定版本,该操作依据携带的选项不同可能会给暂存区和工作区文件带来副作用(重置)。
:当git reset携带一个文件路径时,它起到的作用是重置指定的文件或文件集,但是在 Git 2.23.0 新增了命令git restore,也是用于重置文件,因此与git reset命令功能存在重叠。推荐使用git restore命令重置文件,然后使用reset命令重置仓库版本,这样职责更清晰。

git reset命令语法如下所示:
:我们只介绍git reset重置仓库历史功能。

git reset [--mixed | --soft | --hard | --merge | --keep] [-q] [<commit>]

其中:

$ git reset --soft HEAD~1
$ git add .
$ git commit -m 'same to --amend'

git revert

当需要撤销某个提交的修改时,可以使用git revert命令,其语法如下:

git revert [--[no-]edit] [-n] [-m parent-number] [-s] [-S[<keyid>]] <commit>…
git revert (--continue | --skip | --abort | --quit)

git reset的主要作用是移动HEAD指针,可以剔除某些新提交,缩短提交历史。而git revert的作用不是回退提交,而是逆转提交,即逆转某个提交进行的修改,举例来说,比如提交C2增加了文件2.txt,那么git revert C2会删除2.txt,并生成一个新提交,该新提交其实就是逆转C2的操作:

$ git log --oneline
038a3a3 (HEAD -> master) C3
b8568ed C2                       # bad update,need to revert
841b759 C1

$ git show b856 --oneline --stat
b8568ed C2
 2.txt | 1 +                     # C2 增加了文件 2.txt
 1 file changed, 1 insertion(+)

$ ls
1.txt  2.txt                     # 工作区存在 2.txt

$ git revert b856                # 逆转 C2
Removing 2.txt                   # 2.txt 被删除
[master 456796e] Revert "C2"
 1 file changed, 1 deletion(-)
 delete mode 100644 2.txt

$ git log --oneline
5ea9d73 (HEAD -> master) C4: Revert "C2"
038a3a3 C3
b8568ed C2
841b759 C1

$ ls
1.txt                            # 工作区不存在 2.txt

上述代码的图像示意如下:

git revert

:逆转不会对后续版本有任何影响,即git revert C2不会对提交C3有任何影响。

git revert也支持同时逆转多个提交:

# 表示逆转 HEAD~1 和 HEAD
$ git revert -n HEAD~1 HEAD

# 表示逆转 master~5 到 master~2 之间的所有提交(不包含 master~5,但是包含 master~2)
# git revert -n master~5..master~2

git revert可以使用参数-n, --no-commit来禁止自动提交,即此时只会逆转工作区和暂存区内容,而不会生成一个新提交。

当逆转的提交是一个merge commit时,此时需要使用参数-m, --mainline <parent-number>来指定主线分支,主线分支会被保留,而另一条合并分支会被逆转,通常parent-number的取值为12,大多数情况下,1表示当前分支,2表示合并分支(即被合并的分支)。
parent-number具体查找方法请查看:高级技巧 - 查看合并提交的parent-number

可以简单这样理解,merge commit的内容是由两个parent commit合并而成的,两个parent commit代表了两条分支上的改动合集,因此当要逆转这个merge commit时,需要指明逆转哪条分支上的改动,而另一条分支保持不变。

举个例子:假设master分支存在记录C1 <- C2 <- C3 <- C6dev分支存在记录C1 <- C2 <- C4 <- C5 <- C6,如下图所示:

merge commit

由上图可以看出,C3增加了文件3.txtC4添加了文件4.txtC5增加了文件5.txt,因此其合并提交C6就同时增加了文件3.txt4.txt5.txt

如果此时我们逆转C6,由于C6是一个merge commit,因此此时需要使用-m参数来指定主线分支,假设这里主线分支为master,则如下命令会逆转dev分支的修改:

# 转到 master 分支
$ git switch master

# 保留 master 分支(1 表示当前分支),逆转 dev 分支
$ git revert -m 1 HEAD

上述操作结果如下图所示:

git revert merge commit

可以看到,dev分支上的修改被撤销了,此时的新提交C7的内容与C3是完全一样的,从master分支角度上看,此时的效果就好像是从未发生过合并。

如果这时候在dev分支上修补了漏洞,生成一个新提交C8,如下图所示:

fix bug

如果此时合并C7C8,由于C7逆转了C4C5的操作,因此此时合并就不会包含C4C5的内容,导致dev分支合并内容不充分,解决的方法是对C7进行逆转,恢复C4C5的内容,然后在合并C8,如下图所示:

git merge

最后,当进行协同开发时,由于git reset在绝对意义上说只能缩短版本提交记录,这在多人协作场合就不是那么好用了,因为一般服务器共享版本只能增加历史记录,不能进行回退(除非全员有共识,则可以使用git push -f强制进行更新),这种情况下,如果某个提交是“坏”提交,比如某个 bug 是在一个提交上引入的,如果该提交已上传到服务器,则此时可通过git revert来生成一个新的逆转提交,撤销这个 bug,重新引入正确代码即可。

标签

标签的主要作用就是作为对象文件的别名,方便进行引用。通常将标签设置为指向某个提交,作为该提交的别名,后续直接使用该标签就可以查看该提交详情,无需使用提交数字摘要。不过,一般都会将标签以版本号形式打到一些比较重要的提交上,作为版本迭代。

标签的基本操作如下:

创建

Git 中存在两种类型标签:

查询

标签的查询主要包含如下几种类型:

删除

删除标签使用如下命令:

git tag -d <tag_name>

比如:

# 删除标签 v1.0
$ git tag -d v1.0

检出标签

如果想查看某个标签源码,可以使用如下命令:

git checkout <tag_name>

此时相当于git reset --hard <tag_name>效果,但是当前仓库是处于游离状态(detached HEAD)的,即HEAD指针指向一个具体的提交(cat .git/HEAD),由于游离状态不处于任何分支中,因此不要在该状态下修改文件,否则可能产生副作用(比如修改无法追踪)。
只需切换回某个分支就可以退出这种状态。

分支

分支是 Git 最强大的特性之一,相比于其他依靠复制副本创建分支的版本控制系统,在 Git 中,创建一个分支是非常轻量级的操作,因为 Git 中,分支的本质就是一个指针,因此无论是创建分支、切换分支还是删除分支等操作,速度都特别快。分支模型是 Git 最受欢迎的一个特性,目前几乎所有团队合作项目,都会基于分支模型进行展开,也因此,各种各样的分支使用规范也应运而生。

先介绍下分支的基本使用:

创建分支

创建一个分支的命令如下所示:

git branch <branch_name>

:创建一个新分支其实就是往一个文件中写入 41 个字节(40 个字符哈希值和 1 个换行符),因此速度特别快。

上述命令会基于当前分支的最新提交创建一个新分支,创建完成后,仍停留在当前分支中。

切换分支

切换分支使用的命令如下:

git switch <branch_name>

:Git 提供了一个简便的方法可以切换回上一个分支:git switch -

查询分支

查询本地仓库中存在的分支,基础的有如下两种用法:

创建并切换分支

如果想创建一个分支并同时切换到新创建的分支中,可以使用如下命令:

git switch [<options>] (-c|-C) <new-branch> [<start-point>]

-c对应的长选项为--create,表示创建分支,而git switch可用于切换分支,两者结合,就能实现切换到新创建分支中。
start-point是基准分支,即基于start-point分支上创建新分支new-branch,当start-point未指定时,默认基于当前分支。

一个需要注意的点是,每次切换分支时,工作区和暂存区都会被重置到切换分支最新提交状态,因此,切换分支前必须确保当前工作目录干净(即git commitgit stash),否则无法切换成功,因为 Git 会尽最大能力确保不丢失用户任何修改。

:默认情况下,创建一个新分支,新分支内容会基于当前分支,也即新分支包含当前分支最新提交及其之前所有提交历史,有时候可能需要创建一个空分支,也即一个不包含任何提交记录的全新分支,那么可以使用如下命令:

git switch [<options>] --orphan <new-branch>

举个例子:比如创建一个空分支rare

# 当前分支存在提交
$ git log --oneline -1
7574f83 (HEAD -> master) feat: 111

# 创建并切换到空分支 rare
$ git switch --orphan rare
Switched to a new branch 'rare'

# 空分支不包含任何提交记录
$ git log
fatal: your current branch 'rare' does not have any commits yet

重命名分支

重命名或移动分支命令如下所示:

git branch [<options>] (-m | -M) [<old-branch>] <new-branch>

其中:-m, --move表示移动或重命名分支,如果本地仓库存在同名分支,则需要使用选项-M进行强制重命名。

:如果未指定old-branch,则默认对当前分支进行修改。

删除分支

删除分支需要使用选项-d, --delete,具体如下所示:

git branch -d <branch_name>

-d, --delete只能删除已合并的分支,如果分支未合并,则只能强制进行删除:

git branch -D <branch_name>

合并分支

分支合并使用的命令为git merge,其语法具体如下所示:

git merge [<options>] [<commit>...]
git merge --abort
git merge --continue

分支合并最常见的场景就是将两条分支的变动合并到一起,生成一个新的合并提交。分支合并通常使用三路合并策略,即将当前分支最新提交、合并分支最新提交和两分支的最近公共祖先提交进行一个合并操作,以最近公共祖先提交作为基准点,其他分支上的提交与基准点不同的变动会被进行合并,如果两条分支对同一文件的同一处地方都进行了变动,这时就会产生冲突,需要手动解决该冲突,然后继续恢复合并过程即可,这也就是分支合并的基本原理。

下面以一个例子来驱动介绍分支合并相关内容:

  1. 假设本地仓库当前存在提交C1C2

    $ git init demo_merge && cd demo_merge
    Initialized empty Git repository in /mnt/e/code/temp/learn_git/demo_merge/.git/
    
    $ echo 'C1' > 1.txt
    $ git add . && git commit -m 'C1'
    [master (root-commit) 6210074] C1
     1 file changed, 1 insertion(+)
     create mode 100644 1.txt
    
    $ echo 'C2' > 2.txt
    $ git add . && git commit -m 'C2'
    [master fbca408] C2
     1 file changed, 1 insertion(+)
     create mode 100644 2.txt
    
    $ git log --oneline --graph
    * fbca408 (HEAD -> master) C2
    * 6210074 C1
    

    此时的示意图如下所示:

    master: C1 <- C2
  2. 此时在master分支上创建一个分支dev,然后为dev创建两个新提交:C3C4

    $ git switch -c dev
    Switched to a new branch 'dev'
    
    $ echo 'C3' > 3.txt
    $ git add . && git commit -m 'C3'
    [dev da5704c] C3
     1 file changed, 1 insertion(+)
     create mode 100644 3.txt
    
    $ echo 'C4' > 4.txt
    $ git add . && git commit -m 'C4'
    [dev 2db1365] C4
     1 file changed, 1 insertion(+)
     create mode 100644 4.txt
    
    $ git log --oneline --graph
    * 2db1365 (HEAD -> dev) C4
    * da5704c C3
    * fbca408 (master) C2
    * 6210074 C1
    

    此时的示意图如下所示:

    dev: C3 <- C4
  3. 此时切换到master,合并dev分支:

    $ git switch master
    Switched to branch 'master'
    
    $ git merge dev
    Updating fbca408..2db1365
    Fast-forward                # 使用快进模式
     3.txt | 1 +
     4.txt | 1 +
     2 files changed, 2 insertions(+)
     create mode 100644 3.txt
     create mode 100644 4.txt
    

    从合并结果消息可以看到,本次合并采用了Fast-forward模式,即快进模式,该模式其实就是直接将master指针移动到C4上,因为dev分支上的合并结点C4master分支虽然处于两个不同的分支中,但是位于同一条时间线上,合并的两个结点中,C2C4的直接祖先,所以合并这两者的时候,直接将master指针向前移动就可以了。

    此时的示意图如下所示:

    master: merge fast-forward

    从上述示意图可以清晰看到,Fast-forward模式合并的效率非常高,因为就只是移动了指针而已,但是这种模式的缺点是会丢失分支合并信息,因为没有构造出有向无环图(DAG,directed acyclic graph),此时如果查看历史记录,完全就是单分支记录:

    $ git log --oneline --graph
    * 2db1365 (HEAD -> master, dev) C4
    * da5704c C3
    * fbca408 C2
    * 6210074 C1
    
  4. 如果想保留分支合并信息,那么就需要禁用Fast-forward模式,可通过选项--no-ff来禁止该模式。

    下面介绍禁用Fast-forward模式来合并分支的效果:

    1). 首先将master分支重置到C2状态:

    $ git reset --hard HEAD~2
    HEAD is now at fbca408 C2
    
    $ git log --oneline --graph
    * fbca408 (HEAD -> master) C2
    * 6210074 C1
    

    此时的示意图如下所示:

    master: reset to C2

    2). 然后使用--no-ff合并dev分支:

    $ git merge --no-ff dev -m 'C5: merge with --no-ff'
    Merge made by the 'recursive' strategy.
     3.txt | 1 +
     4.txt | 1 +
     2 files changed, 2 insertions(+)
     create mode 100644 3.txt
     create mode 100644 4.txt
    

    3). 查看此时日志记录:

    $ git log --oneline --graph
    *   65a1d2f (HEAD -> master) C5: merge with --no-ff
    |\
    | * 2db1365 (dev) C4
    | * da5704c C3
    |/
    * fbca408 C2
    * 6210074 C1
    

    可以看到,DAG 图出现了,因为--no-ff会生成一个新提交C5来合并C2C4,这样分支合并就构成有向无环图,分支合并信息就得以保存了。

    此时的示意图如下所示:

    master: merge --no-ff

    :除非明确操作目的,否则建议合并分支时始终使用--no-ff来禁止Fast-forward模式,保留分支合并信息。

  5. 有时候合并分支时,会出现冲突现象。冲突的本质原因是不同的分支对同一个文件相同的位置进行了不同的修改,这样在合并的时候,Git 无法确定保存哪条分支上的修改,于是暂停合并,抛出冲突,交由开发者手动解决该冲突,然后再继续合并过程。

    下面介绍下合并冲突处理流程:
    1). 首先将上述例子master分支重置到C2

    $ git reset --hard fbca
    HEAD is now at fbca408 C2
    
    $ git log --oneline --graph
    * fbca408 (HEAD -> master) C2
    * 6210074 C1
    

    此时的示意图如下所示:

    master: reset to C2

    2). 现在在master分支上创建文件4.txt,并写入数据,进行提交:

    $ echo 'master: C5' > 4.txt
    $ git add 4.txt
    $ git commit -m 'C5'
    [master 94e6038] C5
     1 file changed, 1 insertion(+)
     create mode 100644 4.txt
    

    此时的示意图如下所示:

    master: C5

    3). 此时合并masterdev分支:
    :由于此时master分支所在提交(即C4)不是dev分支所在提交(即C5)的直接祖先,因此合并不是使用Fast-forward模式,而是会将两个分支的末端提交点(即C4C5)以及这两个分支的公共祖先提交(即C2)进行一个简单的三路合并(simple three-way merge)。

    $ git merge dev -m 'C6: simple three-way merge'
    CONFLICT (add/add): Merge conflict in 4.txt     # 4.txt 文件有冲突
    Auto-merging 4.txt
    Automatic merge failed; fix conflicts and then commit the result.
    

    不出意外,此时出现了合并冲突,从输出中可以看到,4.txt文件产生了冲突,原因是C4C5同时对该文件相同位置进行了修改。

    :此时也可以通过git status命令来查看哪些文件产生了冲突:

    $ git status
    On branch master
    You have unmerged paths.
      (fix conflicts and run "git commit")
      (use "git merge --abort" to abort the merge)
    
    Changes to be committed:
            new file:   3.txt
    
    Unmerged paths:                                # 未合并路径
      (use "git add <file>..." to mark resolution)
            both added:      4.txt                 # 冲突文件
    

    4). 此时查看冲突文件4.txt内容:

    $ cat 4.txt
    <<<<<<< HEAD
    master: C5
    =======
    C4
    >>>>>>> dev
    

    可以看到,冲突位置由符号<<<<<<<=======>>>>>>>包裹起来,其中:

    • <<<===之间是主分支的内容。
    • ===>>>之间是合并分支的内容。

    5). 我们可以手动修改该冲突文件,或者使用git mergetool唤起默认冲突比对工具进行修改,修改完成后,还需要将修改完的文件添加到暂存区,然后恢复合并过程:

    $ echo -e 'master: C5\nC4' > 4.txt          # 手动解决冲突
    $ cat 4.txt
    master: C5
    C4
    
    $ git add 4.txt                             # 冲突修改完成,添加文件到暂存区
    
    $ git merge --continue                      # 继续合并过程
    [master 95a28cd] C6: simple three-way merge
    

    合并完成后,查看日志记录:

    $ git log --oneline --graph
    *   95a28cd (HEAD -> master) C6: simple three-way merge
    |\
    | * 2db1365 (dev) C4
    | * da5704c C3
    * | 94e6038 C5
    |/
    * fbca408 C2
    * 6210074 C1
    

    可以看到,三路合并最终生成了一个新提交。

    此时的示意图如下所示:

    master: simple three-way merge

分支规范

分支模型是 Git 的杀手级特性,在团队项目中,分支的使用十分广泛,但是由于分支十分灵活,因此在团队协作时,最好遵循一套标准分支管理规范,方便代码维护与管理,有利于项目持续迭代。

目前主流的分支管理采用多路并发协作模型,具体来说,一个仓库中,主要存在如下几种功能分支:

最后,除了masterdev分支外,其余分支都是临时性分支,也即在合并完后,就立即进行删除。

当然,每个团队都有不同的 git-flow 流程,但大致与上述介绍的差别不大。更多详细内容,可参考如下文章:

储藏

前面说过,在进行分支切换时,必须确保当前工作目录干净,这样分支才能成功切换,但在实际开发中,很可能我们还处于新特性开发过程中,就突然接到一个临时紧急任务需要开发,此时当然是新开一个分支来处理该紧急任务,但是由于当前分支工作目录不干净,无法直接进行分支创建与切换,这时就可以借助git stash功能。

git stash提供了储藏功能,它本质是对分支当前暂存区和工作区目录进行备份,也即储藏当前修改的内容,然后恢复到分支当前最新提交状态,这样分支当前工作目录就处于干净状态,这时候就可进行分支创建与切换了,后续切换回该分支时,再将储藏的修改进行恢复,就可以继续之前未完成的开发了。

储藏涉及的操作主要有如下:

开启储藏

要对当前状态进行储藏,其命令如下:

git stash [push]

git stash默认只储藏被追踪文件,如果想存储未被追踪的文件,可使用-u, --include-untracked参数,如果想存储所有文件(包括.gitignore忽略的文件),可以使用-a, --all参数。

git stash的实现原理其实就是对当前状态进行了一个提交,而且是一个合并提交,简单来说,如果直接运行git stash,那么 Git 会首先依据当前工作目录和暂存区状态,生成一个提交(假设为C2),然后将该提交与当前分支最新提交(假设为C1)进行合并,生成储藏提交。而如果加上-u选项,则 Git 还会对未被追踪的文件进行提交,生成一个提交对象(假设为C3),然后C1C2C3进行一个简单三路合并,生成一个储藏提交对象。
然后.git/refs/stash引用文件指向这个生成的储藏对象文件,多个储藏会生成多个commit对象文件,.git/refs/stash始终指向最新的储藏commit对象,日志文件.git/logs/refs/stash记录了每次stash对应生成的commit对象,当git stash pop时候,从该日志文件找到前一个stash对应的commit对象,设置到.git/refs/stash文件即可,这样就完成了储藏与恢复功能。示意图如下所示:

git stash implementation

:以上git stash原理实现只是本人猜测,并未进行细究,可能与真正实现不符,但本人觉得应该差不多是这个原理。

查询储藏

$ git stash list
stash@{0}: WIP on master: 96c3a4e 111
stash@{1}: WIP on master: 96c3a4e 111
$ git show stash@{0}
commit 7d0d67b196c6c90421dfcd3e17099163a3199839 (refs/stash)
Merge: 96c3a4e 17afeb3 a94cafa
Author: Why8n <Why8n@gmail.com>
Date:   Sun Dec 27 10:57:59 2020 +0800

    WIP on master: 96c3a4e 111

恢复储藏

当需要回到当前分支,恢复先前储藏的修改时,可以使用如下命令:

git stash apply [<stash>]

stash表示储藏的名称或索引(即stash@{0}中的0),未指定时则默认恢复最后一次储藏。
git stash apply恢复现场后,不会删除该储藏。

删除储藏

删除储藏使用如下命令:

git stash drop [<stash>]

恢复并删除储藏

如果想实现恢复后,直接删除储藏,可以使用如下命令:

git stash pop [<stash>]

远程操作

我们可以将仓库托管到远程服务器上,这样全世界所有人都可以基于该远程仓库开展协同工作。

远程仓库最终还是需要先下载到我们本地进行开发,然后再将完成的新特性上传到服务器上。这期间的操作涵盖本地和远程及其两者之间的互操作,因此我将 Git 的远程操作划分为如下几块:

远程仓库设置

如果想要下载远程仓库到本地,那么本地目录首先必须关联到远程仓库,这样才能进行下载以及执行后续的一些联合操作...

本地关联远程仓库主要涉及如下一些操作与配置:

链接远程仓库

远程仓库通常存在于网络上,因此首先在本地第一步操作就是要链接该远程仓库,其实就是将远程仓库添加到本地 Git 仓库中,其语法如下所示:

git remote add [-t <branch>] [-m <master>] [-f] [--tags | --no-tags] [--mirror=<fetch|push>] <name> <url>

:一个仓库可以容纳多个远程仓库。

主要就是使用git remote add命令来为本地 Git 仓库添加一个远程仓库,也可以认为是为远程仓库设置了一个别名,让我们能通过别名引用该远程仓库,而无需使用远程仓库路径。

举个例子:比如,假设本地存在一个 Git 仓库config_git_remote,我们想将其关联到远程仓库https://github.com/Why8n/learn_git中,则只需执行如下操作:

# 创建并初始化本地 Git 仓库
$ git init config_git_remote && cd config_git_remote
Initialized empty Git repository in /mnt/e/code/temp/learn_git/config_git_remote/.git/

# 本地 Git 仓库链目标接远程仓库,以 origin 指代远程仓库
$ git remote add origin 'https://github.com/Why8n/learn_git'

# 查看远程仓库信息,可以看到,设置成功了
$ git remote -v
origin  https://github.com/Why8n/learn_git (fetch)
origin  https://github.com/Why8n/learn_git (push)

:其实此时就可以执行git pull操作了,且可以成功下载源码,但由于本地仓库未设置相关分支,因此下载的源码不会进行合并。

重置远程仓库地址

可以通过命令git remote set-url来重置远程仓库地址,其语法如下所示:

git remote set-url [--push] <name> <newurl> [<oldurl>]
git remote set-url --add <name> <newurl>
git remote set-url --delete <name> <url>

举个例子:仍用上述例子,前面我们设置的远程仓库地址是HTTP的,由于我们本地与服务器已配置了 SSH,将仓库更改为 SSH 通讯会更加方便:

# 重置远程仓库地址
$ git remote set-url origin 'git@github.com:Why8n/learn_git.git'

# 查看远程仓库地址
$ git remote get-url origin
git@github.com:Why8n/learn_git.git

git remote add用于增加新远程仓库,仓库是从无到有的过程,而git remote set-url用于更新仓库地址,仓库更新前必须存在:

# 不能增加已存在的远程仓库
$ git remote add origin 'https://github.com/Why8n/learn_git.git'
error: remote origin already exists.

# 不能更新不存在的远程仓库
$ git remote set-url remote_not_exists 'https://github.com/Why8n/learn_git.git'
error: No such remote 'remote_not_exists'

远程仓库查询

当成功链接远程仓库后,就可以查询远程仓库的一些信息:

重命名远程仓库

其实就是为远程仓库更换一个别名,使用的命令如下所示:

git remote rename <old> <new>

git remote set-url用于重置远程仓库地址,git remote rename用于重置远程仓库别名。

举个例子:将远程仓库别名origin更改为remote_repo01

$ git remote rename origin remote_repo01

$ git remote -v
remote_repo01   git@github.com:Why8n/learn_git.git (fetch)
remote_repo01   git@github.com:Why8n/learn_git.git (push)

删除远程仓库

如果想要删除某个远程仓库,使用如下命令即可:

git remote remove <name>

举个例子:比如删除远程仓库origin

# 手动添加一个新远程仓库,名称为 origin
$ git remote add origin 'git@github.com:Why8n/learn_git.git'

# 查看所有的远程仓库
$ git remote -v
origin  git@github.com:Why8n/learn_git.git (fetch)
origin  git@github.com:Why8n/learn_git.git (push)
remote_repo01   git@github.com:Why8n/learn_git.git (fetch)
remote_repo01   git@github.com:Why8n/learn_git.git (push)

# 删除远程仓库 origin
$ git remote remove origin

# 删除成功
$ git remote -v
remote_repo01   git@github.com:Why8n/learn_git.git (fetch)
remote_repo01   git@github.com:Why8n/learn_git.git (push)

本地操作

本地对远程仓库的操作主要涉及以下内容:

源码下载

前面我们都是通过手动配置远程仓库信息,之后才能进行源码下载,其实,Git 提供了一个简便的命令可以让我们直接下载源码仓库,并自动配置相关信息。这个命令就是git clone,其语法如下所示:

git clone [<options>] [--] <repo> [<dir>]

git clone会下载源码文件到指定目录中,同时会默认做以下几件事:

由于git clone下载远程仓库的同时,也设置了追踪分支,因此当直接使用git fetch时,所有的远程分支都会被拉取到本地,当使用git pull时,除了拉取分支外,还会将拉取到的远程master分支合并到本地master分支中(一个例外情况就是使用了--single-branch)。

拉取分支更新

要拉取远程仓库的更新内容,可以使用命令git fetch,其语法如下所示:

git fetch [<options>] [<repository> [<refspec>...]]
git fetch [<options>] <group>
git fetch --multiple [<options>] [(<repository> | <group>)...]
git fetch --all [<options>]

简单来说,git fetch主要有如下几种常用操作:

分支合并

通过git fetch拉取的更新,可以通过git merge合并到本地对应追踪分支上。其语法如下所示:

git merge [<options>] [<commit>...]
git merge --abort
git merge --continue

举个例子:比如想为当前分支合并远程分支origin/master,命令如下所示:

$ git merge origin/master

:如果未指定要进行合并的远程分支(比如,上述命令省略origin/master),则默认合并当前分支追踪的远程分支。

:另一种很常用的分支合并方式是git rebase,具体详情请查看后文:高级技巧 - git rebase

合并更新

git pull其实就是git fetchgit merge的组合命令,可以一步到位完成分支拉取与合并。其语法如下所示:

git pull [<options>] [<remote> <remote_branch>:<local_branch>]

其中,一些常用的options有:

:当git pull未指定参数时,默认会下载所有远程分支,并将当前分支与其追踪的远程分支进行合并。
:当未指定local_branch时,默认将远程分支remote_branch与本地当前分支进行合并。

举个例子:假设本地存在masterdev分支,且master分支追踪远程分支origin/master

# 切换到本地 master 分支
$ git switch master 

# 下载所有远程分支,并且将`origin/master`合并到当前本地分支上
$ git pull

# 下载远程分支`origin/develop`,合并到本地分支`master`上
$ git pull origin develop:master

# 由于当前处于 master 分支上,因此上面命令可简化为如下
$ git pull origin develop

推送分支

当本地仓库进行了一些修改后,如果想将修改上传到远程仓库中,可以使用命令git push进行推送。其语法如下所示:

git push [<remote> <local_branch>:<remote_branch>]

可以看到,git pushgit pull的格式差不多,但是两者local_branchremote_branch位置相反,虽然看似两者的结构不同,但其实是一致的,只要将代码来源作为基准,其统一的结构就为:<代码来源>:<代码去处>,git push是将本地代码上传到服务器,因此是<local_branch>:<remote_branch>,而git pull是从服务器下载源码到本地,因此是<remote_branch>:<local_branch>

简单来说,git push主要有如下几点需要注意:

建立追踪关系

前面说过,使用git clone命令下载的远程仓库,会自动对远程分支进行追踪。我们也可以手动对本地仓库各分支建立追踪关系。

手动建立分支追踪关系大致有如下三种方法:

取消分支追踪

取消分支追踪只需使用--unset-upstream选项即可,其格式如下所示:

git branch --unset-upstream [<local_branch>]

:当local_branch未指定时,默认取消当前分支的追踪关系。

举个例子:比如取消dev分支追踪关系:

$ git branch --unset-upstream dev

远程分支

对远程分支的操作,主要涉及如下:

下载/检出远程分支

使用git clone下载远程仓库时,虽然会下载所有分支源码,但默认只检出活动分支。当然由于所有远程分支都已下载到本地,我们也可以检出其他分支源码到工作目录中,只需使用checkout命令即可:

# 检出 origin/develop 分支到工作目录
$ git checkout origin/develop

# 注意:此时 Git 处于游离态
$ git branch
* (HEAD detached at origin/develop)
  dev
  master

# 游离态本质是 HEAD 指针指向了一个具体提交,而不是分支
$ cat .git/HEAD
b51743fac29d8a99557c560bb517971eb77a2725

从上述操作可以看到,虽然我们在本地检出远程分支,但却会让 Git 处于游离态,这种操作适用于查看分支代码,但不适用于对代码进行修改,因为游离态修改代码存在丢失等风险。因此,通常如果我们对某个远程分支感兴趣,或者需要修改该远程分支源码,比较好的做法是将该远程分支下载合并到本地对应追踪分支上,效果其实就相当于本地下载了远程分支。

简单来说,下载远程分支或者说本地激活远程分支大致有如下几种方法:

查看远程分支

要查看远程分支,只需使用-r, --remotes选项即可:

git branch {-r | --remotes}

删除远程分支

要删除远程分支,大致有如下几种方法:

远程标签

对远程标签的操作,主要有如下内容:

推送标签

Git 中推送标签到远程仓库,主要有如下两种操作:

下载指定标签

默认情况下,git clone会下载所有的远程标签,但是本地不会进行检出。我们也可以添加-b, --branch直接选择下载指定标签,这样下载完成后会自动检出该标签。

注:git clone -b <tag>下载完成后会自动检出该标签内容到工作目录,但会导致 Git 处于游离态。

举个例子:比如想检出远程标签v1.0,则有如下做法:

# 直接指定要下载的标签。
$ git clone 'git@github.com:Why8n/learn_git.git' -b v1.0 

删除远程标签

要删除服务器上的远程标签,大致有如下几种方法:

# 法一
git push <remote> --delete <tag>

# 法二
git push <remote> :<tag>

:删除远程标签并不会同时删除本地相同标签,如果还需要删除本地标签,可以使用git tag -d <tag>命令。

举个例子:比如现在要删除服务器远程仓库origin的标签v1.0

# 查询远程仓库,此时存在 v1.0
$ git ls-remote --tags origin | grep -i v1.0
b51743fac29d8a99557c560bb517971eb77a2725        refs/tags/v1.0

# 删除远程仓库标签 v1.0
$ git push origin --delete v1.0              # 或者:git push origin :v1.0
To github.com:Why8n/learn_git.git
 - [deleted]         v1.0

# 再次查询远程仓库,v1.0 已被删除
$ git ls-remote --tags origin | grep -i v1.0
上一篇下一篇

猜你喜欢

热点阅读