笔者在进入梳理了有关于 Git 的基本概念及命令行下的使用方法。
对于 Windows 用户,不要使用 CMD 或者 Jet Branins IDE 提供的终端执行 Git 命令,因为 Git 会使用到 Linux 环境下的 vim 工具 ( 或者是其它命令 ) ,而 Windows 系统并不支持,因此在执行过程中会给出类似 Git error: cannot spawn XXX: No such file or directory 的提示,有时可能会影响 Git 的功能。
因此,安装 Git 后,请在项目根目录下点击右键:Git Bash Here 并使用 Git 。该终端将允许我们在模拟 Linux 的环境下来和 Git 进行交互。
随着笔者对 Git 的了解逐渐加深,本文的内容可能会随之更新。文内若出现部分理解出现偏颇的内容,欢迎大家指正。此为 Git 的官方文档:Git - Documentation (git-scm.com)
1. 四大区域与三个层次
在使用 Git 之前,首先要对这四个区域有一个基本的了解:
工作区间:即经过 git init 初始化后的项目目录。Git 会自动追踪该目录下的文件内容变更的情况。
暂存区:通过 add 命令 ( 可理解成 "选中" 操作) 告知 Git 有哪些文件需要被托管,这些文件 ( 及更改的内容 ) 会被移动到暂存区。
本地库:通过 commit 命令 ( 真正的 "提交" 操作 ) 告知 Git,将当前暂存区的内容永久保存。
远程库:Github,码云等云端代码托管平台,我们最终将本地库的代码存储到这里,以便于团队的跨网协作。
此外,对于一个 Git 库,还可分为三个层级:
一个库可包含多条分支,每一条分支代表了该 Git 项目的不同版本 ( 正式版本,测试版本,开发版本,特性开发版本等等 )。一条分支内又包含了多条提交记录,可以将每一次提交理解成某个版本的一个对应版本号。
2. 本地操作
2.1 创建本地分支并切换 - Branch / Checkout
对于任意一个想要托管到 Git 的项目工程,在根目录下使用以下命令进行初始化:
$ git init
Git 将创建一个名为 .git 的隐藏文件夹,项目级别的相关配置会被放入其中。本篇作为 Git 的实用性质介绍,这里不去详细解析该文件夹的内容和功能。首先,使用 add 命令选中要被 Git 托管的文件,这些文件 ( 及其变更 ) 会被移动到暂存区中等待提交。
# 选中某个文件
$ git add <path>
# 选中目录下的全部文件
$ git add .
紧接着就是通过 commit 命令将刚才选中的文件从暂存区永久保存到本地库内。 -m 的功能相当于代码的注释,尤其在团队合作中,我们需要提供足够的信息来描述此次提交的缘由,或者是维护 / 更新的内容。
如果要将某个文件从暂存区移除,笔者的做法是先在工作区间内将此文件删除,然后再将剩余的文件添加到暂存区:
$ rm <path>
$ git add .
如果没有带上 -m 参数,则 Git 会打开 vim 强制要求补充描述信息,否则 Git 将拒绝执行这次提交。
$ git commit -m "why this commit"
如果此次提交没有新增任何文件 ( 言外之意是所有更新都是基于已有文件进行的 ) ,则可以通过 -a 参数 "略过暂存区" 而直接提交到本地库。
$ git commit -am "why this commit"
一个 Git 项目的各种环境 ( 比如正式版本,特性开发版本,开发版本 ) 区分都是 "基于分支" 实现的。刚才的 commit 命令更确切的说是 "提交到了本地库的 master 分支" 上。
对初始 Git 项目做第一次提交之后,Git 将创建出本地库的第一条名为 master 的分支。使用 branch 命令能够查看本地库内拥有的全部分支。其中,带 * 标记的分支表示目前所处的分支。
$ git branch
此外,使用 git status 能够查看到该分支的状态。该命令在后文中会经常使用:
$ git status
# 首先会打印出当前所在的分支名称
# On branch <branchName>
# 当暂存区内有等待 commit 提交的文件时:
# Changes to be committed:
# (use "git reset HEAD <file>..." to unstage)
#
# ...
# 当工作区间存在没有被 add 命令添加的文件时:
# Untracked files:
# (use "git add <file>..." to include in what will be committed)
#
# ...
# 在工作区间的文件全部 add + commit 完毕后,将显示:
# nothing to commit, working tree clean
Git 能够以一个分支为镜像 "深复制" 出另一个新的分支。对这个分支的后续更改都不会影响到当前分支。每个分支将独立记录项目目录下的文件状态。
$ git branch <nbn>
# 另一个方法是 checkout 创建并进入到该分支:
$ git checkout -b <nbn>
nbn ( new branch name ) 指这个新分支的名称。相对的,附带 -d <bn> 或 -D <bn> 表示删除分支,但是不可以删除掉当前所在的分支。
$ git branch -d <bn>
# 当某个分支存在未合并完的内容时,Git 可能会拒绝删除此分支。
# 下面的命令是强制删除分支:
$ git branch -D <bn>
如果要在多个分支工作,则需要手动切换。 otherBn 指代其它分支的名字。
$ git checkout <otherBn>
在切换分支时,工作区间内的文件也会随之切换到对应的版本。下面的语句块演示了这一过程:
# 假定目前在 master 分支,创建了新文件
$ touch master_cfg
$ ls # 只能看到一个 master_cfg
# 创建新分支,并切换到新分支中
# 可以使用一行命令:
# git checkout -b backup
$ git branch backup
$ git checkout backup
# 在该分支下创建新文件,并 commit
$ touch backup_cfg
$ git add backup_cfg
$ git commit -m "create a new file in the branch:backup"
$ ls # 能看到两个文件:master_cfg, backup_cfg
# 回到主分支下
$ git checkout master
$ ls # 项目目录又只剩下一个 master_cfg 了,backup_cfg 只会在 backup 分支下存在。
2.2 临时保存工作进度 - Stash
在切换分支之前,Git 要求所有被追踪文件的修改都已经提交到本地分支上。
# xxx.txt 发生了修改,但是没有被 add 到暂存区。
Changes not staged for commit:
(use "git add <file>..." to update what will be committed)
(use "git checkout -- <file>..." to discard changes in working directory)
modified: xxx.txt
# xxx.txt 发生了修改,但是没有被 commit 到本地分支。
Changes to be committed:
(use "git reset HEAD <file>..." to unstage)
modified: xxx.txt
如果 git status 有提示以上的信息,则切换分支会被拒绝:
error: Your local changes to the following files would be overwritten by checkout:
xxx.txt
Please commit your changes or stash them before you switch branches.
Git 提示,此时要么通过 commit 提交更改,要么通过 stash 将这些改动临时保存起来:
$ git stash
$ git stash save "message"
# git stash save "save the modified in xxx.txt"
save "message" 用于为临时存档添加描述信息,虽然是非必须选项,但是建议添加。通过 stash list 能够查询本地库通过 stash 保存的所有临时存档,以及这些存档所在的分支:
$ git stash list
# stash@{0}: On opps: save the modified in xxx.txt
恢复存档有两种形式,pop 和 apply:
# n 是 stash list 中显示的某个临时存档的序列号。
# 恢复这个临时存档,并且此存档从 stash list 中删掉。
$ git stash pop stash@{n}
# 恢复这个临时存档,并且此存档仍然在 stash apply 中保留。
$ git stash apply stash@{n}
同样,临时存档也可以不恢复而直接删除:
# 删除指定的临时存档。
$ git stash drop stash@{n}
# 删除所有通过 stash 保存的临时存档。
$ git stash clear
2.3 解决多分支下的文件冲突 - Merge
通常,我们会将一些系统的新功能切到一个独立的 feature 分支进行开发,待功能完善后再将其迁移到主分支 master 中并上线 ( 不同公司对此都会有相应的约束和规范 ) 。这里需要引入另一个命令来实现分支的合并:
$ git merge <other_bn>
切记,这条命令是将其它分支的内容合并到当前分支 ( 而不是反过来的 ) 。
合并操作难免会带来一些冲突情况,譬如两条分支可能都对一份文件进行了更改。如果这个更改属于 "追加" 性质,则 Git 会直接将新增内容补充到当前分支中,并要求操作者像 commit 命令一样补充此次 merge 操作的描述和原因。
不过,大部分情况下,代码冲突都不是简单地追加,这种情况下就需要人为地介入修改。当 Git 无法自动处理冲突时,它会给出类似的提示:
Auto-merging xxxx
CONFLICT (content): Merge conflict in xxxx
Automatic merge failed; fix conflicts and then commit the result.
另外,Git Bash 显示当前的分支从 (xxx) 变成了 ( xxx | MERGING ) ,这是 Git 在提醒我们人工处理代码冲突。在该模式下,我们无法通过 push 命令将本地分支提交到对应的远程分支,必须在人工解决冲突后使用 commit 命令重新提交一个 "进行了合并且解决冲突后的新版本"。此时,Git 会解除 MERGING 状态。
发生冲突的位置会被 Git 替换为以下格式的文本:
<<<<<<< HEAD
modified by 1...
=======
modified by demo2
>>>>>>> github_origin/master
这个文本表示:在当前版本中,该位置的文本是 modified by 1... ,而在另一个 github_origin/master 分支中,该处的文本是 modified by demo2 。对于大型项目而言,想要通过人工方式来检阅所有的代码冲突是一件不现实的事情,因此这里借助 diff 命令使 Git 显示所有发生冲突的位置。
$ git diff
注:merge 也被视作是一次对本地分支的更新。此外,除了 merge 命令之外,其它 Git 命令:cherry-pick,fetch,revert 等都有可能引发代码冲突。
3. 在远程库中维护代码
远程库的选择有很多,除了 Github 之外,国内类似的代码托管网站还有码云。无特殊说明的情况下,后续的 "远程库" 说法均默认指代 Github 网站。
本地库的内容会同步到远程库 ( Remote Repository ), 以便于云端开发。一个本地库的代码可以提交到多个远程库。笔者在后文可能会出于省略的目的而使用 "提交到远程库","提交远程库的某分支" 之类的描述,实际上,这些说法是不确切的,严谨的说法是 "提交到某个远程库的某个分支"。我们在 Git 命令行中能看到如此的表示:
origin/master
它指代的是名为 origin 的远程库中的 master 分支。下图描述了远程库和本地库的映射关系:不仅仅是分支对应分支,每条分支内的每条提交记录也是相对应的。
3.1 将本地库关联到一个新的远程库 - Remote
首先,登录到 Github 网页 ( 需要先注册 Github 账号 ) 并创建一个新的空仓库,各种选项和命名可根据自己的需要而定。
一个刚创建好的空仓库看起来是这样的:并且它目前没有关联任何分支,也没有任何提交记录。同时,它留下了两种形式的 url ,分别是 HTTPS 形式的链接和 SSH 形式的链接。
下一步便是利用这个链接在本地库中关联远程库。url 原则上可以选择 HTTPS 和 SSH 的任意一种形式,这会影响到我们日后将以何种方式向远程库提交代码。如果对 SSH 方式暂时不了解,在这里可以先选择 HTTPS 形式的链接。同时为了日后能够引用这个远程库,还需要指定该远程库的命名 rn ( remote name,比如 origin )
$ git remote add <rn> <url>
以下命令能够检查本地库关联的所有远程库以及对应的 url :
$ git remote -v
前文已经提到过,一个本地库可以对应多个远程库,而这些多个远程库的别名不允许重复。和关联相对应的,下方的命令用于删除远程库关联:
$ git remote rm <rn>
3.2 向远程库提交代码 - Push
通过 push 命令,我们就可以将本地库的更新提交到远程库了,下面对一些参数和细节做说明:
$ git push <rn> <lbn>
rn 是已经关联的远程库名称,而 lbn ( local branch name ) 是指定提交本地库的分支名。前文已经提过,本地库和远程库是分支 "对应" 分支的关系,这条命令将本地分支的更新提交到了远程库的同名分支中 ( 如果在远程库中,这个分支原本不存在,则它就会被新创建出来 ) 。
远程库会将第一个提交的分支设置为默认分支。一个非空的远程库至少要存在一个默认分支,这个默认分支是不可删除的。不过,我们可以登录 Github 官网中主动选择默认分支,或者做出其它的设置。
只有远程库的管理者有权利直接使用 push 命令提交代码 ( 非管理者参考后文的 fork ) 。push 的方式取决于本地库用何种形式与远程库相关联:
第一种为 HTTPS 协议形式。这样每次 push 时,Git 都会要求我们提供 Github 的账号密码来通过身份验证。
第二种为 SSH 协议形式。只需在本地生成一对公私钥,然后在 Github 中将公钥注册,后续在本机的 push 将免去身份验证环节。SSH 的配置部分参考后文的补充部分。
如果想将对某远程库的关联切换为另一种协议,只需要借助下面的命令:
$ git remote set-url <rn> <url>
其中,rn 为指定的远程库名 ,url 则是项目对应的 HTTPS / SSH 链接 ( 比如:git@github.com:me/Test.git )
"用户账户","用户密码" 是指在各个远程库平台申请的账号,用于避免其他人向远程库恶意提交或覆盖上垃圾代码。Git 则是专注于 Control Version ( 或称 Ctrl + V ) 的工具,它有身份验证环节 ,但是本身并不提供账号密码。
当本地分支在上一次 push 之后进行了多次本地提交,那么下一次 push 时会将这些新的提交记录一并上传到远程分支上。
3.3 在新环境中继续开发项目 - Clone
当需要在另一台新机器中继续开发项目时,或者开发组的人员要维护一个远程库时,首先应使用 clone 命令,从远程库那里各自生成本地库及对应分支。克隆 —— 顾名思义,就是得到的本地库代码和远程库完全相同。这保证了我们无论在哪个开发环境,或者说对于一个开发组的任何人而言,下载,修改,并提交的都是同一处来源的代码。
$ git clone <url>
默认的 clone 命令将从指定的 url ( HTTPS 或者 SSH 链接均可 ) 获取远程库的全部分支,但是 Git 只会主动创建一个与远程库默认分支相对应的本地分支。如果要在本地关联远程库的其它分支,可以通过下面的命令来完成:
# -a 参数将显示所有 ( 本地库 + 远程库 ) 的分支。
$ git branch -a
# e.g:
# git checkout -t origin/feature
# 这会在本地创建出一个 feature 分支,该分支和远程库的同名分支相对应。
$ git checkout -t <rn>/<rbn>
在 Github 网页版中,可以查看到哪一个是默认分支。如果要克隆项目时直接指定远程库的分支,则需要添加 -b 参数:
$ git clone -b <rbn> <url>
其中,rbn ( remote branch name ) 指远程库中对应的分支名称。
除此之外,clone 命令会另 Git 自动建立起本地库和远程库的关联,该远程库默认被命名为 origin 。
3.4 保证项目进度的一致性 - Fetch / Pull
我们总是基于最新的项目进度进行开发,同样,我们也很清楚,并发 / 并行总是会引发一些同步上的问题。现在假设这样的生产环境:A 和 B 两个人同时维护同一个远程库分支。
如果 A 在向远程库分支 push 之前,B 却早已更新了分支内的一些内容,则 A 目前的本地分支就不再是 "最新的" 了。显然,此时如果 A 的代码更新成功了,那么就会导致 B 的更新丢失。
或者,A 自己在另一个工作环境中提交的工作,却没有在当前的工作环境中及时更新,这也会引发类似的问题。在上述情况下,Git 会拒绝 push :
error: failed to push some refs to 'git@github.com:XXX/xxx.git'
hint: Updates were rejected because the remote contains work that you do
hint: not have locally. This is usually caused by another repository pushing
hint: to the same ref. You may want to first integrate the remote changes
hint: (e.g., 'git pull ...') before pushing again.
hint: See the 'Note about fast-forwards' in 'git push --help' for details.
解决方案便是通过 fetch/pull 命令率先更新自己的本地分支。
fetch 命令会使 Git 先从远程库中下载更新内容,并与本地库中的代码进行 merge 操作。在提交代码前,A 可以通过 git diff 查看到远程库的最新版本中做出了哪些修改,A 需要考虑该作何修改。
$ git fetch <rn> <rbn>
fetch 命令需要提供远程库名 rn,以及对应的分支名称 rbn 。如果 A 和 B 之间维护的代码存在少许耦合,交叉的情况,A 需要根据 B 的代码来做相应维护,在这种情况下,推荐使用 fetch 命令。
pull 命令会使 Git 在下载更新内容后直接覆盖掉本地库中的代码。
$ git pull <rn> <rbn>
如果 A 和 B 两者维护的代码内容完全独立,A 完全不需要关注 B 做出了哪些改动,这种情况下,推荐使用 pull 命令。
最极端的方式,就是在 push 命令后加 -f 参数进行强制提交。除非遇到了特殊情况,比如后文会用到该参数对远程库的分支进行强制回滚,否则不会用到这个参数。在远程库中,可以通过设置保证在正常情况下远程库自动拒绝强制提交,以免发生工作内容覆盖,丢失等问题。
3.5 远程库的跨团队协作 - Fork
首先,fork 是远程库行为,而非 Git 的命令。
在介绍 fork 之前,首先说说 Git 提供的 clone 命令。对于 clone 得到的本地分支,虽然可以在本地库进行自定义更新,但由于我们不是该远程库的管理者,所以无法在 push 时提供 HTTPS / SSH 验证信息,自然就无法向远程库提交对该项目的后续更新。这个道理很好理解,我们没有权限向不属于自己的远程库中提交代码。
或许,我们在 "用轮子" 的过程中有了一个不错的改进灵感,并希望原远程库的管理者充分考虑这些意见。登录到 Github 官网,通过 fork 按钮复制这份远程库到自己名下。显然,对于复制来的远程库,我们可以下载到本地库中自行维护,并有充分的权利通过 push 进行维护。整个逻辑类似于:
当时机成熟时,便可以向原远程库的管理者发起一个 pull request 请求,管理者将会对比这两个远程库的差异,并决定是否采纳这些更新。
4. git log 与 cherry-Pick
随着项目开发的进行,每条分支都会延展成一条长长的时间线:每一个关键的 "时间节点" 都代表了一次 commit 更新记录 ( 而 commit 的原因可能是多种,也许是正常提交,也许是为了解决 merge ,revert 过程的冲突代码 ) 。
Git 是基于增量更新的。这意味着,分支每一次更改都会添加一点 "新玩意" ( 它可能代表着一个系统的新功能,由多个额外的文件组成,我们暂且将它比喻为 ”cherry“ ) 。偶尔,我们想在另一个分支的时间线中,挑选一些其它分支的 "Cheeries" 并融入进来,这就是一个 cherry-Pick 的过程。
为了查看这些 "Cherries" ,首先需要了解如何查看每条分支的提交历史:
$ git log <bn> # 打印完整的,指定分支的提交历史
$ git log # 打印当前分支的提交历史
$ git log <bn> --oneline # 打印简略的提交历史
笔者截取了其中两条来展示 git log 的完整格式:
# 普通的 commit 记录
commit 5c1d691c476632e916f8190391f345d2203a8770
Author: xxxx <you@example.com>
Date: Wed Dec 16 15:48:23 2020 +0800
your description
# git merge 会记录合并的两个 commit hash.
commit a01db86557eaf443277ac641840af3fd81fcabad
Merge: ea0180f b3bb2f2
Author: xxxx <you@example.com>
Date: Sun Dec 20 16:39:49 2020 +0800
Merge branch 'that_branch' into this_branch
每一条记录都有此分支内唯一一个哈希值作为 id ( 使用时只复制前 6 位即可 )。除此之外,它还记录了该记录的提交者 ( 这依赖于本地的 Git 配置,和远程库的账号没有必然联系 ),提交的时间,以及提交者对此次操作的描述。
不仅仅是 cherry-pick 依赖 git log,后续的回滚 ( Reset ),恢复 ( Revert ) 等操作同样需要它。尤其是当
现在,通过一个实例介绍如何使用 cherry-pick 命令。假设这样的场景:主分支 master 中经历了三次提交,每一次提交都假定只更新一个新功能 ( 这里以创建一个文本 functionx 来代替 ),而另一个 dev 则只去获取主分支 master 第一次,第三次提交时添加的新内容。
首先使用 log 命令打印出主分支 master 的相关提交记录 ( 或许现在我们能理解为什么 Git 要求每一次都要为 merge 和 commit 操作留下描述信息了 ):
# 第一次只更新 (创建) 了新文件 function1
commit ef9e7c162e51414986ecd0352ad23b68f69b4956 (HEAD -> master)
Author: xx <**@qq.com>
Date: Thu Dec 17 13:29:00 2020 +0800
add new function3
# 第二次只更新 (创建) 了新文件 function2
commit 13c07865d974ca8c4e83889b2690e0d9acccfc6c
Author: xx <**@qq.com>
Date: Thu Dec 17 13:28:38 2020 +0800
add new function2
# 第三次只更新 (创建) 了新文件 function3
commit c5ddee49aabde4d99aa2b5816d9bdb0b551eebb5
Author: xx <**@qq.com>
Date: Thu Dec 17 13:27:44 2020 +0800
add new function1
切换到 dev 分支下,通过 cherry-pick 获取其中第一次和第三次更新的文件。
$ git checkout dev
# cherry-pick 命令后面可以跟上任意个更改的 Hash 值。
$ git cherry-pick ef9e7c1 c5ddee4
$ ls #项目目录下新增了 function3 和 function1 ,但是没有 function2
cherry-pick 后面的参数除了更改记录的哈希值之外,还可以选择分支名。和 merge 命令不同的是,它只获取该分支的最后一次更改。
$ git cherry-pick <bn>
回到案例中,如果 dev 刚才执行的是以下命令,它将只会获取到 function3。
$ git checkout dev
$ git cherry-pick master
$ ls #项目目录下只新增了 master 最后一次更新的 function3.
cherry-pick 可以获取 "从 A 之后到 B 的所有更新",这要求 A 一定是发生在 B 前面的更改。下面演示了 dev 分支如何获取从 function2 到 function3 的更新:
# git cherry-pick <A_hash>..<B_hash>
$ git cherry_pick ef9e7c1..c5ddee4
$ ls # 项目目录只新增了 master 的最后两次更新,因此显示 function2, function3.
如果表述 "获取从 A 到 B 的所有更新",则需要在 A 的后面标注一个 ^ 符号。
# git cherry-pick <A_hash>^..<B_hash>
$ git cherry-pick ef9e7c1^..c5ddee4
$ ls # 项目目录新增了 master 的连续三次更新,因此显示了 function1, function2, function3.
4.1 Cherry-Pick 的冲突解决方式
接下来讨论 cherry-pick 发生冲突的情况。假设在 dev 已经具备 function2 相关内容的情形下,再去获取 master 分支的 function1 ~ function3 ,则 Git 会在执行到 function2 时暂停并提示:
# 第一个 function1 不存在冲突,更新成功
[dev 60c08d9] new function1
Date: Thu Dec 17 18:40:39 2020 +0800
1 file changed, 0 insertions(+), 0 deletions(-)
create mode 100644 function1
# 第一个 function2 存在冲突,更新失败。
error: could not apply 24d27a5... new function2
hint: after resolving the conflicts, mark the corrected paths
hint: with 'git add <paths>' or 'git rm <paths>'
hint: and commit the result with 'git commit'
git status 命令将显示如下的状态:
On branch dev
You are currently cherry-picking commit 24d27a5.
(fix conflicts and run "git cherry-pick --continue")
(use "git cherry-pick --abort" to cancel the cherry-pick operation)
Unmerged paths:
(use "git add <file>..." to mark resolution)
both added: function2
no changes added to commit (use "git add" and/or "git commit -a")
同时,Git Bash 的命令行显示目前的分支从 (dev) 变更为 (dev|CHERRY-PACKING) 。对于代码冲突问题,cherry-pick 提供了三种选择:
$ git cherry-pick --abort # 回滚到执行 cherry-pick 之前的状态,即便之前有成功执行的更新,也会被撤销。
$ git cherry-pick --quit # 不再继续向后的 cherry-pick 操作,之前成功执行的更新会被保留。
$ git cherry-pick --continue # 继续执行未完成的 cherry-pick,这需要用户手动解决冲突问题。
第一二种方式都比较好理解,我们在这里将尝试第三种解决方式。根据提示信息,Git 要求我们主动处理发生冲突的文件,并在修复后重新将它添加到暂存区中。
# 假定笔者处理了冲突部分
$ vim function2
# 重新将文件添加到暂存区
$ git add function2
# 通知 Git 继续执行未完成的 cherry-pick, 从 function2 开始。
$ git cherry-pick --continue
cherry-pick 将重新将 function2 记录到本地分支内,对此,我们还需要添加对此更改的描述信息。
# 第二个 function2 修复了冲突,更改提交信息后,显示更新成功。
[dev fb54d23] merge the function2
Date: Thu Dec 17 18:41:15 2020 +0800
1 file changed, 1 insertion(+)
# 第三个 function3 不存在冲突,更新成功。
[dev d798361] commit new function3
Date: Thu Dec 17 18:41:48 2020 +0800
1 file changed, 0 insertions(+), 0 deletions(-)
create mode 100644 function3
Git 会执行完剩下的 cherry-pick 任务,最终 Git Bash 显示分支从原来的 (dev|CHERRY-PICKING) 状态变更回 (dev) 时表示 cherry-pick 已经完成。此时,function1,function3 及合并后的 function2 都被成功地添加到了 dev 分支内。
4.2 Cherry -Pick vs Merge
和 cherry-pick 相比,merge 命令会试图将其中一条分支从创建到现在为止的所有更改全部合并到此分支。
而 cherry-pick 操作的粒度则更加细致:它能够摘取出其它分支的部分更新并合并到本分支内。
有关于 cherry-pick 命令,这里推荐 阮一峰的 cherry-Pick 章节。
5. 回滚操作 - Reset
回滚操作同样是版本控制的一个重要内容。Git 为此提供了两种命令:reset 和 revert ,在这里首先介绍前者。
# 回滚到指定位置
$ git reset <op> <commit_Hash>
# 回滚最后 n 次更新
$ git reset <op> HEAD~<n>
其中,op 参数有三种选择:--hard,--soft,--mixed。注意,如果使用了 reset 命令,无论是何种方式的回滚,都会导致原提交记录被删除。下面通过一个例子来说明:假定在该分支中做了一次 "无用" 的提交,并且在该提交中,创建了一个名为 useless 的文件。
5.1 回滚的三种形式
第一种,--hard 是最彻底,最简单地回滚方式。该方式会使分支完全回退到某个过去的提交刚刚完成的状态。举例:
commit 7929397d75dc81b265c9a67016c1efd4ec568ac9 (HEAD -> master)
Author: xx <**@qq.com>
Date: Fri Dec 18 11:53:19 2020 +0800
useless commit
commit a560b0e04e86bca1666cd7df1375efb4bb912e6d
Author: xx <**@qq.com>
Date: Thu Dec 17 19:03:36 2020 +0800
lasted commit
如果使用以下命令之一,分支将回退到 a560b0e 号提交的状态。
$ git reset --hard a560b0
$ git reset --hard HEAD~1
使用 git status 检查当前的状态,这个 792939 号更改就好像从来没发生过一样,并且曾经在 792939 号提交的 useless 文件也彻底消失了。
On branch master
nothing to commit, working tree clean
第二种,--mixed 是相对 "温和" 的处理方式。该方式相当于将本次提交的文件 "打回":useless 文件会从暂存区移除 ( 相当于这个文件还没有被 add 进来 ) ,但是仍然能够在工作区间内找到它。在执行完回滚命令后,使用 git status 将显示:
On branch master
Untracked files:
(use "git add <file>..." to include in what will be committed)
useless
第三种,--soft 是 "更温和" 的处理方式。该方式相当于只将这个提交动作 "打回":useless 文件将处于在暂存区等待提交的状态。在执行完回滚命令后,使用 git status 将显示:
On branch master
Changes to be committed:
(use "git reset HEAD <file>..." to unstage)
new file: useless
5.2 利用回滚简化提交记录
--soft 模式还有一种用法,那就是将多个零碎的提交 ( 这些频繁的提交可能都是维护一个功能) 合并成一个单次提交,从而达到简化提交记录的效果。举例:
commit 8f1756abfd6b06c67af4f9acb8c6d8b6b892e955 (HEAD -> master)
Author: xx <**@qq.com>
Date: Fri Dec 18 12:49:04 2020 +0800
modified controller for the function...
commit 3518520e0cd7e5d33ec432488929515b08b110b5
Author: xx <**@qq.com>
Date: Fri Dec 18 12:48:11 2020 +0800
create repository for the function...
commit cdaa05af52f3e160be6818613f0f0d9434474148
Author: xx <**@qq.com>
Date: Fri Dec 18 12:46:53 2020 +0800
create controller for the function...
commit a560b0e04e86bca1666cd7df1375efb4bb912e6d
Author: xx <**@qq.com>
Date: Thu Dec 17 19:03:36 2020 +0800
do something ...
首先通过 git reset --soft 将这三次提交期间改动的文件全部挪动到暂存区内 ( 以下两个命令取其一均可 ):
$ git reset --soft a560b0e04
$ git reset --soft HEAD~3
使用 git status 显示:
On branch master
Changes to be committed:
(use "git reset HEAD <file>..." to unstage)
new file: Controller.java
new file: Repository.java
这三次的更改对于 a560b0e 号提交而言都属于新增内容,因此它们都被认为是 "new file" 。检查无误后重新提交:
$ git commit -m "Full function:xxx"
再次查看该分支的提交记录:
commit b3bb2f24636003069e4486f41bc7ecd48c08e1f3 (HEAD -> master)
Author: xx <**@qq.com>
Date: Fri Dec 18 13:02:32 2020 +0800
Full function:xxx
commit a560b0e04e86bca1666cd7df1375efb4bb912e6d
Author: xx <**@qq.com>
Date: Thu Dec 17 19:03:36 2020 +0800
do something...
该提交包含了原先三次提交的所有更新,并且使得该分支的提交记录变得更加清晰。
5.3 回滚远程库分支
Git 没有命令能够直接使远程库的分支回滚。想要实现这个目的,我们需要进行两步操作:首先,在本地库对此分支进行回滚,其次,将回滚后的本地分支通过 push -f 强制覆盖掉远程库的对应分支。
# 确定要返回的状态
$ git log
$ git reset --hard <commit_hash>
$ git push -f <rbn> <lbn>
6. 恢复操作 - Revert
恢复操作的目的和回滚操作相同,都是为了撤销已提交的更改。然而,两者在实现方式上有所差异。直观的体现是回滚 ( Reset ) 会删除掉提交记录 ( 通过 git log 来查看 ) ,我们无法再去追溯原有的更改,而 revert 则依靠和原有提交做 "相反的熵减操作",以此来实现等效于回滚的效果。
显然,这种方式不会删掉原有的提交记录,反而会新增用于抵消之前操作的另一条 commit 更新。和 Reset 相比,它的优势是能够通过提交记录再次追溯到原有的更改。语法形式如下:
# 恢复某一个 commit
$ git revert <a_hash>
# 下面的用法类似于 cherry-pick
# 恢复一系列 commit,不包括 <a_hash>
$ git revert -n <a_hash>..<b_hash>
# 恢复一系列 commit,包括 <a_hash>
$ git revert -n <a_hash>^..<b_hash>
由于 revert 也被视作是一次更新,因此 Git 会要求补充对此次恢复操作的描述信息。此外,一条 revert commit 对应一条 commit ,如果一次性恢复了多条 commit,则在 git log 上也会增加对应数目的 revert commit 。
6.1 Revert 中的祖父悖论 ?
相比于 reset 将 "整个时间线" 回退,revert 更像是回到某个时间点 ( 一次 commit ) 修改历史。
比如我们曾经在过去的提交 A 中创建了一个名为 future 的文件,而后续的提交 B ,C 都对 future 文件进行了修改。现在如果恢复了提交 A ( 意味着 future 文件将消失 ),那么在 B,C 提交对 future 文件的修改势必受到牵连 —— Git Bash 显示分支状态从 (xxx) 变更为了 (xxx|REVERTING) 。
# 123bfbd (HEAD -> opps) add 2 in file 'future'
# f56f502 add 1 in file 'future'
# 4a0ce50 create a file 'future'
$ git revert 4a0ce50
# error: could not revert 4a0ce50... create a file 'future'
# hint: after resolving the conflicts, mark the corrected paths
# hint: with 'git add <paths>' or 'git rm <paths>'
# hint: and commit the result with 'git commit'
好在,我们有充分的自主权决定这个文件将何去何从:当 git revert 遇到代码冲突时,要么 rm 删掉这个 "扰乱时间线" 的文件,要么修正之后通过 add 命令将它重新放入暂存区 ( 根据实际需求而定,比如本案例中选择将 future 从暂存区直接删除 ),然后通过 --continue 参数示意 Git 继续执行:
$ git revert --continue
# 和 cherry-pick 类似,revert 有另外的两个选择:
# git revert --abort
# gti revert --quit
7. Git 杀手锏 - Rebase
本段对 Rebase 的描述主要来源于此篇博客:【Git】rebase 用法小结 - 简书 (jianshu.com)
Rebase 是一条强大的命令 —— 你可以通过在交互界面编写逻辑,以此任意地修改分支在某段区间内的提交记录,甚至将这段提交记录移植给其它分支。
# 修改从 <startPoint> 之后到现在的提交记录。
$ git rebase -i <startPoint>[^]
# 修改从 <startPoint> 之后到 <endPoint> 的提交记录。
$ git rebase -i <startPoint>[^] <endPoint>
# 修改最近的 n 个提交记录。
$ git rebase HEAD~<n>
# 将修改好的提交记录移植到其它分支中 ( cherry-pick )
$ git rebase <startPoint>[^] <endPoint> --onto <other_bn>
如果要包含这个 startPoint ,仍然可以通过 ^ 符号来实现。在执行完这个命令后,我们首先会进入到 vim 编辑器内 ( 笔者对做了一些翻译处理 ):
# rebase 命令选中的记录区间会按从前到后的顺序逐行打印。
pick e4f076c add 1 in function4 # 最早的提交记录
pick 9ae7197 add 2 in function4
pick dfd4ce7 add 3 in function4
pick fbe2372 Full function:xxx # 最近的提交记录
# Rebase 7778996..fbe2372 onto 7778996 (4 commands)
#
# Commands:
# p, pick = use commit
# -> 选择这个提交
# r, reword = use commit, but edit the commit message
# -> 选择这个提交,重新编辑提交信息
# e, edit = use commit, but stop for amending
# -> 选择这个提交,并在执行此提交时停下来 ( 修改这个提交 )
# s, squash = use commit, but meld into previous commit
# -> 选择这个提交,并和之前的提交合并。
# f, fixup = like "squash", but discard this commit's log message
# -> 选择这个提交,与之前合并提交的同时,忽略掉有关于此提交的描述。
# x, exec = run command (the rest of the line) using shell
# -> 执行 shell
# d, drop = remove commit
# -> 移除这个提交
#
# These lines can be re-ordered; they are executed from top to bottom.
# 这些提交历史自上而下地被执行,换句话说,你可以重新排序这些提交历史。
# If you remove a line here THAT COMMIT WILL BE LOST.
# 如果你删除了某一行,则相关的提交将会丢失。
# However, if you remove everything, the rebase will be aborted.
# 然而,如果你删去了所有行,则这条 rebase 指令会被 aborted 。
# Note that empty commits are commented out
# 空提交会被注释掉
或许是它太过强力,以至于 Git 为 Rebase 制定了一条黄金准则:“No one shall rebase a shared branch” — Everyone about rebase 。简单来说,如果你的此分支已经提交到了远程库,并且别人已经通过 pull 获取了它,那么就不要再使用 rebase 命令了。
下面我们将基于这段提交历史实现几个案例,function4 是一个普通的文本文件,add x in funtion4 表示 "本次提交在该文件内追加了 x" 。为了方便复用,请提前通过 branch / checkout 创建该分支的副本。
commit dfd4ce7d115102917740a1572944cba9a7474cff (HEAD -> feature)
Author: xx <**@qq.com>
Date: Thu Dec 17 19:03:36 2020 +0800
add 3 in function4
commit 9ae7197b3086225eec48ae376e139e02e0f77b25
Author: xx <**@qq.com>
Date: Thu Dec 17 19:03:03 2020 +0800
add 2 in function4
commit e4f076c66cbc7848f5aad1bc9f0e9cf87b695474
Author: xx <**@qq.com>
Date: Thu Dec 17 19:02:19 2020 +0800
add 1 in function4
commit 7778996155d5f630adf5ad88595fe10fb711ab36
Author: xx <**@qq.com>
Date: Thu Dec 17 18:41:48 2020 +0800
earlier commit...
7.1 移除提交记录
前文我们已经介绍如何利用 reset --hard 达到同样的目的 ( 甚至该方式更简单 ),在这里出于练习目的,使用 rebase 来完成。首先通过 git rebase -i HEAD~3 进入交互式编辑页面,将原先的 pick 修改为 drop:
drop e4f076c add 1 in function4
drop 9ae7197 add 2 in function4
drop dfd4ce7 add 3 in function4
保存并 wq 退出,等待执行完毕后通过 git log --oneline 观察结果:
7778996 (HEAD -> feature) earlier commit
...
显然,最近的三次提交被删除了。
7.2 简化提交记录
前文我们已经介绍如何利用 reset --soft 达到同样的目的,在这里仍使用 rebase 来完成。首先通过 git rebase -i HEAD~3 进入交互式编辑页面:
pick e4f076c add 1 in function4
squash 9ae7197 add 2 in function4
squash dfd4ce7 add 3 in function4
这会进入另一个编辑界面,用于整合这三次提交的描述信息 ( 这三条描述最终会合并再一条提交内 ):
# This is a combination of 3 commits.
# This is the 1st commit message:
add 1 in function4
# This is the commit message #2:
add 2 in function4
# This is the commit message #3:
add 3 in function4
保存并 wq 退出,等待执行完毕后通过 git log --oneline 观察结果:
commit 68b21c1decb155e4426d17fa6b3742eaa431c32d (HEAD -> opps)
Author: xx <**@qq.com>
Date: Thu Dec 17 19:02:19 2020 +0800
add 1 in function4
add 2 in function4
add 3 in function4
commit 7778996155d5f630adf5ad88595fe10fb711ab36
Author: xx <**@qq.com>
Date: Thu Dec 17 18:41:48 2020 +0800
earlier commit...
如果我们希望去掉后两次的描述信息,而直接在被合并的第一次提交的描述信息中做一些修改:
e e4f076c add 1 in function4
f 9ae7197 add 2 in function4
f dfd4ce7 add 3 in function4
执行,Git 将显示:
Stopped at e4f076c... add 1 in function4
You can amend the commit now, with
git commit --amend
Once you are satisfied with your changes, run
git rebase --continue
同时,状态将从 feature 更变为 (feature|REBASE-i 1/3) ,它表示 rebase 操作中途暂停了 ( 和 revert ,cherry-pick 等执行逻辑类似 ) 。此时的 git status 将提示:
interactive rebase in progress; onto 7778996
Last command done (1 command done):
edit e4f076c add 1 in function4
Next commands to do (2 remaining commands):
fixup 9ae7197 add 2 in function4
fixup dfd4ce7 add 3 in function4
(use "git rebase --edit-todo" to view and edit)
You are currently editing a commit while rebasing branch 'feature' on '7778996'.
(use "git commit --amend" to amend the current commit)
(use "git rebase --continue" once you are satisfied with your changes)
在这里,我们仅仅为了修改 e4f076c 号提交的描述信息 ( 因此,在刚才的编辑中可以将 e 更改为 r ),因此直接 commit 并示意 Git 继续执行即可:
$ git commit --amend
# 更新自己的提交信息
$ git rebase --continue
7.3 移植提交记录
现在,我们将通过 rebase 命令在原 feature 分支上最新的三次提交记录 "转嫁" 给 target 分支。这从逻辑上来看,和在 target 分支上执行了 git cherry-pick feature 4f076^..dfd4ce 是等效的:
# git log feature
commit dfd4ce7d115102917740a1572944cba9a7474cff (HEAD -> feature)
Author: xx <**@qq.com>
Date: Thu Dec 17 19:03:36 2020 +0800
add 3 in function4
commit 9ae7197b3086225eec48ae376e139e02e0f77b25
Author: xx <**@qq.com>
Date: Thu Dec 17 19:03:03 2020 +0800
add 2 in function4
commit e4f076c66cbc7848f5aad1bc9f0e9cf87b695474
Author: xx <**@qq.com>
Date: Thu Dec 17 19:02:19 2020 +0800
add 1 in function4
commit 7778996155d5f630adf5ad88595fe10fb711ab36
Author: xx <**@qq.com>
Date: Thu Dec 17 18:41:48 2020 +0800
earlier commit...
# ---------------------------------------------------------------------------------------
# git log target
commit 7778996155d5f630adf5ad88595fe10fb711ab36
Author: xx <**@qq.com>
Date: Thu Dec 17 18:41:48 2020 +0800
earlier commit...
这一次的 rebase 命令要带上 --onto 参数:
$ git rebase e4f076^ dfd4ce --onto target
下一步,便是切换到 target 分支中获取这移植得来的三次提交:借助 reset 命令来将 target 提交记录的 "头指针" HEAD 移动到最新的 dfd4ce 提交上去:
$ git checkout target
# 切换到 target 分支时, Git 会提示:
# Previous HEAD position was dfd4ce7 add 3 in function4
# 表示该分支有三个新增,但未关联起来的提交记录。
$ git reset --hard dfd4ce
8. 附录:为远程库设置 SSH Key
这里选取 Github 作为远程库。首先使用这条命令来测试本机和 Github 仓库的 SSH 连接:
$ ssh -T git@github.com
本机在没有和 Github 建立 SSH 连接的条件下,会提示:
git@github.com: Permission denied (publickey).
使用以下命令创建一个 SSH 公钥,字符串任选 ( 网上经常说该参数为自己在某远程库的注册邮箱,其实两者并没有什么关系 ),没有特殊需求的情况下一路回车即可:
ssh-keygen -t rsa -C "anyString"
这会在你的用户目录下创建一个 .ssh 文件夹,其中 id_rsa.pub 存放的是命令行生成的公钥。将该文件的内容提取出来,登录到 Github 官网,在个人设置中将其添加即可。
这一步隐含地将该 SSH 和你的远程库账号绑定了一起,因为只有你能够登录自己的账户并执行这一步操作。
9. 参考文章
Git常规配置与用法_个人文章 - SegmentFault 思否
为Github账户设置SSH key_孙海峰的博客-CSDN博客
Git错误non-fast-forward后的冲突解决_Linux教程_Linux公社-Linux系统门户网站 (linuxidc.com)