Files
CategoryResourceRepost/极客时间专栏/geek/研发效率破局之道/个人效能/25 | 玩转Git:五种提高代码提交原子性的基本操作.md
louzefeng bf99793fd0 del
2024-07-09 18:38:56 +00:00

24 KiB
Raw Blame History

你好我是葛俊。今天我们来聊一聊Git吧。

毫无疑问Git是当前最流行的代码仓管理系统可以说是开发者的必备技能。它非常强大使用得当的话可以大幅助力个人效能的提升。一个最直接的应用就是可以帮助我们提升代码提交的原子性。如果一个团队的成员都能熟练使用Git的话可以大大提高团队代码的模块化、可读性、可维护性从而提高团队的研发效能。但可惜的是现实情况中由于Git比较复杂用得好的人并不多。

所以接下来我会通过两篇文章与你详细讲述如何使用Git助力实现代码原子性。今天这篇文章我先与你分享Git支持原子性的5种基础操作下一篇文章则会给你介绍Facebook开发人员是怎样具体应用这些基础操作去实现代码原子性的。

通过这两篇文章,我希望你能够:

  1. 了解在分布式代码仓管理系统中,如何通过对代码提交的灵活处理,实现提交的原子性;
  2. 帮你学习到Git的实用技巧提高开发效率。

我在第21篇文章中提到,代码提交的原子性指的是,一个提交包含一个不可分割的特性、修复或者优化。如果用一个提交完成一个功能,这个提交还是会比较大的话,我们需要把这个功能再拆分为子功能。

为什么要强调代码提交的原子性呢这是因为它有以下3大好处

  • 可以让代码结构更清晰、更容易理解;
  • 出了问题之后方便定位,并可以针对性地对问题提交进行“回滚”;
  • 在功能开关的协助下可以让开发者尽快把代码推送到origin/master上进行合并。这正是持续集成的基础。

而Git之所以能够方便我们实现原子性提交主要有两方面的原因

  • Git提供方便、灵活的提交、分支处理功能使得我们可以灵活地产生提交、修改提交、拆分提交甚至改变提交的先后顺序。
  • Git是一个分布式代码仓管理系统每个开发人员在本地都有一个代码仓从而可以放心在本地代码仓中使用上述功能不用操心会影响到远程共享代码仓。

下面我就来与你分享Git支持原子性的5种基础操作具体包括

  1. 用工作区改动的一部分产生提交;
  2. 对当前提交进行拆分;
  3. 修改当前提交;
  4. 交换多个提交的先后顺序;
  5. 修改非当前提交。

需要注意的是在接下来的两篇文章里我只会与你详细介绍针对原子性相关的操作而关于Git的一些基础概念和使用方法推荐你参考“图解Git”这篇文章。

基本操作一:把工作区里代码改动的一部分转变为提交

如果是把整个文件添加到提交中操作很简单先用git add <文件名>把需要的文件添加到Git暂存区然后使用git commit命令提交即可。这个操作比较常见我们应该都比较熟悉。

但在工作中一个文件里的改动常常会包含多个提交的内容。比如开发一个功能时我们常常会顺手修复一些格式规范方面的东西再比如一个功能比较大的时候改动常常会涉及几个提交内容。那么在这些情况下为了实现代码提交的原子性我们就需要只把文件里的一部分改动添加到提交中剩下的部分暂时不产生提交。针对这个需求Git提供了git add -p命令。

比如我在index.js文件里有两部分改动一部分是添加一个叫作timestamp的endpoint另一部分是使用变量来定义一个魔术数字端口

## 显示工作区中的改动
&gt; git diff
diff --git a/index.js b/index.js
index 63b6300..986fcd8 100644
--- a/index.js
+++ b/index.js
@@ -1,8 +1,14 @@
+var port = 3000  ## &lt;-- 魔术数字变量化
 var express = require('express')
 var app = express()

## vvv 添加endpoint
+app.get('/timestamp', function (req, res) {
+  res.send('' + Date.now())
+})
+
 app.get('/', function (req, res) {
   res.send('hello world')
 })

-app.listen(3000)
+// Start the server
+app.listen(port) ## &lt;-- 端口魔术数字变量化

这时运行git add -p index.js命令Git会把文件改动分块儿显示并提供操作选项比如我可以通过y和n指令来选择是否把当前改动添加到Git的提交暂存区中也可以通过s指令把改动块儿再进行进一步拆分。通过这些指令我就可以选择性地只把跟端口更改相关的改动添加到Git的暂存区中。

&gt; git add -p index.js
diff --git a/index.js b/index.js
index 63b6300..986fcd8 100644
--- a/index.js
+++ b/index.js
@@ -1,8 +1,14 @@
+var port = 3000
 var express = require('express')
 var app = express()

+app.get('/timestamp', function (req, res) {
+  res.send('' + Date.now())
+})
+
 app.get('/', function (req, res) {
   res.send('hello world')
 })

-app.listen(3000)
+// Start the server
+app.listen(port)
Stage this hunk [y,n,q,a,d,s,e,?]? s
Split into 3 hunks.
@@ -1,3 +1,4 @@
+var port = 3000
 var express = require('express')
 var app = express()

Stage this hunk [y,n,q,a,d,j,J,g,/,e,?]? y
@@ -1,7 +2,11 @@
 var express = require('express')
 var app = express()

+app.get('/timestamp', function (req, res) {
+  res.send('' + Date.now())
+})
+
 app.get('/', function (req, res) {
   res.send('hello world')
 })

Stage this hunk [y,n,q,a,d,K,j,J,g,/,e,?]? n
@@ -4,5 +9,6 @@
 app.get('/', function (req, res) {
   res.send('hello world')
 })

-app.listen(3000)
+// Start the server
+app.listen(port)
Stage this hunk [y,n,q,a,d,K,g,/,e,?]? y

当整个文件的所有改动块儿都处理完成之后通过git diff --cached命令可以看到我的确只是把需要的那一部分改动也就是端口相关的改动添加到了暂存区:

&gt; git diff --cached
diff --git a/index.js b/index.js
index 63b6300..7b82693 100644
--- a/index.js
+++ b/index.js
@@ -1,3 +1,4 @@
+var port = 3000
 var express = require('express')
 var app = express()

@@ -5,4 +6,5 @@ app.get('/', function (req, res) {
   res.send('hello world')
 })

-app.listen(3000)
+// Start the server
+app.listen(port) 

通过git diff命令我们可以看到endpoint相关的改动仍留在工作区

&gt; git diff
diff --git a/index.js b/index.js
index 7b82693..986fcd8 100644
--- a/index.js
+++ b/index.js
@@ -2,6 +2,10 @@ var port = 3000
 var express = require('express')
 var app = express()

+app.get('/timestamp', function (req, res) {
+  res.send('' + Date.now())
+})
+
 app.get('/', function (req, res) {
   res.send('hello world')
 })

最后再通过git commit命令我就可以产生一个只包含端口相关改动的提交实现了将本地代码改动的一部分转变为提交的目的。

如果你想深入了解git add -p的内容可以参考这篇文章

通过git add -p我们可以把工作区中的代码拆分成多个提交。但是如果需要拆分的代码已经被放到了一个提交中怎么办如果这个提交已经推送到了远程代码仓共享分支那就没有办法了。但如果这个提交还只是在本地我们就可以对它进行拆分。

基本操作二:对当前提交进行拆分

所谓当前提交指的是当前分支的HEAD指向的提交。

我继续以上面的代码示例向你解释应该如何操作。假如我已经把关于endpoint的改动和端口的改动产生到了同一个提交里具体怎么拆分呢

这时我可以先“取消”已有的提交也就是把提交的代码重新放回到工作区中然后再使用git add -p的方法重新产生提交。这里的取消是带引号的因为在Git里所有的提交都是永久存在的所谓取消只不过是把当前分支指到了需要取消的提交的前面而已。

首先我可以用git log查看历史并使用git show确认提交包含了endpoint改动和端口改动

## 查看提交历史
&gt; git log --graph --oneline --all
* 7db082a (HEAD -&gt; master) Change magic port AND add a endpoint
* 352cc92 Add gitignore file for node_modules
* e2dacbc (origin/master) Added the simple web server endpoint
...


## 查看提交
&gt; git show
commit 7db082ab0f105ea185c89a0ba691857b55566469 (HEAD -&gt; master)
...

diff --git a/index.js b/index.js
index 63b6300..986fcd8 100644
--- a/index.js
+++ b/index.js
@@ -1,8 +1,14 @@
+var port = 3000
 var express = require('express')
 var app = express()

+app.get('/timestamp', function (req, res) {
+  res.send('' + Date.now())
+})
+
 app.get('/', function (req, res) {
   res.send('hello world')
 })

-app.listen(3000)
+// Start the server
+app.listen(port)

然后用git branch temp命令产生一个临时分支temp指向当前HEAD。temp分支的作用是预防代码丢失。如果后续工作出现问题的话我可以使用git reset --hard temp把代码仓、暂存区和工作区都恢复到这个位置从而不会丢失代码。

&gt; git branch temp

&gt; git log --graph --oneline --all
* 7db082a (HEAD -&gt; master, temp) Change magic port AND add a endpoint
* 352cc92 Add gitignore file for node_modules
* e2dacbc (origin/master) Added the simple web server endpoint
...

接下来运行git reset HEAD^命令把当前分支指向目标提交HEAD^也就是当前提交的父提交。同时在没有接hard或者soft参数时git reset会把目标提交的内容同时复制到暂存区但不会复制到工作区。所以工作区的内容仍然是当前提交的内容仍然有endpoint相关改动和端口相关改动。也就是说这个命令的效果就是让我回到了对这两个改动进行提交之前的状态

&gt; git reset HEAD^
Unstaged changes after reset:
M index.js

&gt; git status
On branch master
Your branch is ahead of 'origin/master' by 1 commit.
  (use &quot;git push&quot; to publish your local commits)

Changes not staged for commit:
  (use &quot;git add &lt;file&gt;...&quot; to update what will be committed)
  (use &quot;git checkout -- &lt;file&gt;...&quot; to discard changes in working directory)

 modified:   index.js

no changes added to commit (use &quot;git add&quot; and/or &quot;git commit -a&quot;)
15:06:58 (master) jasonge@Juns-MacBook-Pro-2.local:~/jksj-repo/git-atomic-demo


## 改动在工作区
&gt; git diff 
diff --git a/index.js b/index.js
index 63b6300..986fcd8 100644
--- a/index.js
+++ b/index.js
@@ -1,8 +1,14 @@
+var port = 3000
 var express = require('express')
 var app = express()

+app.get('/timestamp', function (req, res) {
+  res.send('' + Date.now())
+})
+
 app.get('/', function (req, res) {
   res.send('hello world')
 })

-app.listen(3000)
+// Start the server
+app.listen(port)


## 输出为空
&gt; git diff --cached

最后我就可以使用上面介绍过的git add -p的方法把工作区中的改动拆分成两个提交了。

基本操作三:修改当前提交

如果只需要修改Commit Message的话直接使用git commit --amend命令Git就会打开你的默认编辑器让你修改修改完成之后保存退出即可。

如果要修改的是文件内容可以使用git add、git rm等命令把改动添加到暂存区再运行git commit --amend最后输入Commit Message保存退出即可。

基本操作四:交换多个提交的先后顺序

有些时候我们需要把多个提交交换顺序。比如master分支上有两个提交A和BB在A之上两个提交都还没有推送到origin/master上。

这时我先完成了提交B想把它先单独推送到origin/master上去就需要交换A和B的位置使得A在B之上。我可以使用git rebase --interactive选项interactive可以简写为-i来实现这个功能。

首先还是使用git branch temp产生一个临时分支确保代码不会丢失。然后使用git log --oneline --graph来确认当前提交历史

&gt; git log --oneline --graph
* 7b6ea30 (HEAD -&gt; master, temp) Add a new endpoint to return timestamp
* b517154 Change magic port number to variable
* 352cc92 (origin/master) Add gitignore file for node_modules
* e2dacbc Added the simple web server endpoint
* 2f65a89 Init commit created by installing express module

接下来,运行

&gt; git rebase -i origin/master

Git会打开我的默认编辑器让我选择rebase的具体操作

pick b517154 Change magic port number to variable
pick 7b6ea30 Add a new endpoint to return timestamp

# Rebase 352cc92..7b6ea30 onto 352cc92 (2 commands)
#
# Commands:
# p, pick &lt;commit&gt; = use commit
# r, reword &lt;commit&gt; = use commit, but edit the commit message
# e, edit &lt;commit&gt; = use commit, but stop for amending
# s, squash &lt;commit&gt; = use commit, but meld into previous commit
# f, fixup &lt;commit&gt; = like &quot;squash&quot;, but discard this commit's log message
# x, exec &lt;command&gt; = run command (the rest of the line) using shell
# b, break = stop here (continue rebase later with 'git rebase --continue')
# d, drop &lt;commit&gt; = remove commit
# l, label &lt;label&gt; = label current HEAD with a name
# t, reset &lt;label&gt; = reset HEAD to a label
# m, merge [-C &lt;commit&gt; | -c &lt;commit&gt;] &lt;label&gt; [# &lt;oneline&gt;]
# .       create a merge commit using the original merge commit's
# .       message (or the oneline, if no original merge commit was
# .       specified). Use -c &lt;commit&gt; to reword the commit message.
#
# 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.
#
# Note that empty commits are commented out

rebase命令一般翻译为变基意思是改变分支的参考基准。具体到git rebase -i origin/master命令就是把从origin/master之后到当前HEAD的所有提交也就是A和B重新有选择地放到origin/master上面。你可以选择放或者不放某一个提交也可以选择放置顺序还可以选择将多个提交合并成一个等等。另外这里说的放一个提交指的就是在HEAD之上应用一个提交的意思。

Git rebase -i打开编辑器时里面默认的操作列表是把原有提交全部原封不动地放到新的参考基准上去具体到这个例子是用两个pick命令把A和B先后重新放到origin/master之上如果我直接保存退出的话结果跟rebase之前没有任何改变。

这里因为我需要的操作是交换A和B的顺序所以交换两个pick指令行保存退出即可。Git rebase就会先后把B和A放到origin/master上。

pick 7b6ea30 Add a new endpoint to return timestamp
pick b517154 Change magic port number to variable

# Rebase 352cc92..7b6ea30 onto 352cc92 (2 commands)
# ...

至此我就完成了交换两个提交的先后顺序。接下来我可以用git log命令来确认A和B的确是交换了顺序。

## 以下是 git rebase -i origin/master 的输出结果
Successfully rebased and updated refs/heads/master.


## 查看提交历史
&gt; git log --oneline --graph --all
* 65c41e6 (HEAD -&gt; master) Change magic port number to variable
* 40e2824 Add a new endpoint to return timestamp
| * 7b6ea30 (temp) Add a new endpoint to return timestamp
| * b517154 Change magic port number to variable
|/
* 352cc92 (origin/master) Add gitignore file for node_modules
* e2dacbc Added the simple web server endpoint
* 2f65a89 Init commit created by installing express module

值得注意的是A和B的commit SHA1改变了因为它们实际上是新产生出来的A和B的拷贝原来的两个提交仍然存在图中的阴影部分我们还可以用分支temp找到它们但不再需要它们了。如果temp分支被删除A和B也会自动被Git的垃圾收集过程gc清除掉。

其实git rebase -i的功能非常强大除了交换提交的顺序外还可以删除提交、和并多个提交。如果你想深入了解这部分内容的话可以参考“Git 工具 - 重写历史”这篇文章。

基本操作五:修改非头部提交

在上面的基本操作二、三、四中我与你介绍的都是对当前分支头部的一个提交或者多个提交进行操作。但在工作中为了方便实现原子性我们常常需要修改历史提交也就是修改非头部提交。对历史提交操作最方便的方式依然是使用强大的git rebase -i。

接下来我继续用上面修改A和B两个提交的顺序的例子来做说明。在还没有交换提交A和B的顺序时也就是B在A之上的时候我发现我需要修改提交A。

首先我运行git rebase -i origin/master然后在弹出的编辑窗口中把原来的“pick b517154”的一行改为“edit b517154”。其中b517154是提交A的SHA1。

edit b517154 Change magic port number to variable
pick 7b6ea30 Add a new endpoint to return timestamp

# Rebase 352cc92..7b6ea30 onto 352cc92 (2 commands)
# ...

而“edit b517154”是告知Git rebase命令在应用了b517154之后暂停后续的rebase操作直到我手动运行git rebase --continue通知它继续运行。这样当我在编辑器中保存修改并退出之后git rebase 就会暂停。

&gt; git rebase -i origin/master
Stopped at b517154...  Change magic port number to variable
You can amend the commit now, with

  git commit --amend

Once you are satisfied with your changes, run

  git rebase --continue

22:29:35 (master|REBASE-i) ~/jksj-repo/git-atomic-demo &gt;

这时我可以运行git log --oneline --graph --all确认当前HEAD已经指向了我想要修改的提交A。

&gt; git log --oneline --graph --all
* 7b6ea30 (master) Add a new endpoint to return timestamp
* b517154 (HEAD) Change magic port number to variable
* 352cc92 (origin/master) Add gitignore file for node_modules
* e2dacbc Added the simple web server endpoint
* 2f65a89 Init commit created by installing express module

接下来我就可以使用基本操作二中提到的方法对当前提交也就是A进行修改了。具体来说就是修改文件之后用git add <文件名>然后再运行git commit --amend。

## 检查当前HEAD内容
&gt; git show
commit b51715452023fcf12432817c8a872e9e9b9118eb (HEAD)
Author: Jason Ge &lt;gejun_1978@yahoo.com&gt;
Date:   Mon Oct 14 12:50:36 2019

    Change magic port number to variable

    Summary:
    It's not good to have a magic number. This commit changes it to a
    varaible.

    Test:
    Run node index.js and verified the root endpoint still works.

diff --git a/index.js b/index.js
index 63b6300..7b82693 100644
--- a/index.js
+++ b/index.js
@@ -1,3 +1,4 @@
+var port = 3000
 var express = require('express')
 var app = express()

@@ -5,4 +6,5 @@ app.get('/', function (req, res) {
   res.send('hello world')
 })

-app.listen(3000)
+// Start the server
+app.listen(port)
 
 
## 用VIM对文件进行修改在注释部分添加&quot;at a predefined port&quot;
&gt; vim index.js


## 查看工作区中的修改
&gt; git diff
diff --git a/index.js b/index.js
index 7b82693..eb53f5f 100644
--- a/index.js
+++ b/index.js
@@ -6,5 +6,5 @@ app.get('/', function (req, res) {
   res.send('hello world')
 })

-// Start the server
+// Start the server at a predefined port
 app.listen(port)
22:40:10 (master|REBASE-i) jasonge@Juns-MacBook-Pro-2.local:~/jksj-repo/git-atomic-demo
&gt; git add index.js
 
 
## 对修改添加到提交A中去
&gt; git commit --amend
[detached HEAD f544b12] Change magic port number to variable
 Date: Mon Oct 14 12:50:36 2019 +0800
 1 file changed, 3 insertions(+), 1 deletion(-)
22:41:18 (master|REBASE-i) jasonge@Juns-MacBook-Pro-2.local:~/jksj-repo/git-atomic-demo


## 查看修改过后的A。确认其包含了新修改的内容&quot;at a predefined port&quot;
&gt; git show
commit f544b1247a10e469372797c7dd08a32c0d59b032 (HEAD)
Author: Jason Ge &lt;gejun_1978@yahoo.com&gt;
Date:   Mon Oct 14 12:50:36 2019

    Change magic port number to variable

    Summary:
    It's not good to have a magic number. This commit changes it to a
    varaible.

    Test:
    Run node index.js and verified the root endpoint still works.

diff --git a/index.js b/index.js
index 63b6300..eb53f5f 100644
--- a/index.js
+++ b/index.js
@@ -1,3 +1,4 @@
+var port = 3000
 var express = require('express')
 var app = express()

@@ -5,4 +6,5 @@ app.get('/', function (req, res) {
   res.send('hello world')
 })

-app.listen(3000)
+// Start the server at a predefined port
+app.listen(port)

执行完成之后我就可以运行git rebase --continue完成git rebase -i的后续操作也就是在A之上再应用提交B并把HEAD重新指向了B从而完成了对历史提交A的修改。

## 继续运行rebase命令的其他步骤
&gt; git rebase --continue
Successfully rebased and updated refs/heads/master.


## 查看提交历史
&gt; git log --oneline --graph --all
* 27cba8c (HEAD -&gt; master) Add a new endpoint to return timestamp
* f544b12 Change magic port number to variable
| * 7b6ea30 (temp) Add a new endpoint to return timestamp
| * b517154 Change magic port number to variable
|/
* 352cc92 (origin/master) Add gitignore file for node_modules
* e2dacbc Added the simple web server endpoint
* 2f65a89 Init commit created by installing express module

经过rebase命令我重新产生了提交A和B。同样的A和B是新生成的两个提交原来的A和B仍然存在。

以上,就是修改历史提交内容的步骤。

如果我们需要对历史提交进行拆分的话步骤也差不多首先使用git rebase -i在需要拆分的提交处使用edit指令然后在git rebase -i暂停的时候使用基本操作2的方法对目标提交进行拆分拆分完成之后运行git rebase --continue即可。

小结

今天我与你介绍了Git支持代码提交原子性的五种基本操作包括用工作区改动的一部分产生提交、对当前提交进行拆分、修改当前提交、交换多个提交的先后顺序以及对非头部提交进行修改。

掌握这些基本操作可以让我们更灵活地对代码提交进行修改、拆分、合并和交换顺序为使用Git实现代码原子性的工作流打好基础。

其实,这些基本操作非常强大和实用,除了可以用来提高提交的原子性外,还可以帮助我们日常开发。比如,我们可以把还未完成的功能尽快产生提交,确保代码不会丢失,等到后面再修改。又比如,可以产生一些用来帮助自己本地开发的提交,始终放在本地,不推送到远程代码仓。

在我看来Git学习曲线比较陡而且长帮助手册也可以说是晦涩难懂但一旦弄懂它能让你超级灵活地对本地代码仓进行处理帮助你发现代码仓管理系统的新天地。git rebase -i命令就是一个非常典型的例子。一开始你会觉得它有些难以理解但搞懂之后就超级有用可以帮助你高效地解决非常多的问题。所以在我看来在Git上投入一些时间绝对值得

为了方便你学习,我把这篇文章涉及的代码示例放到了GitHub推荐你clone下来多加练习。

思考题

  1. 对于交换多个提交的先后顺序除了使用rebase -i命令外你还知道什么其他办法吗
  2. 文章中提到,如果一个提交已经推送到了远程代码仓共享分支,那就没有办法对它进行拆分了。这个说法其实有些过于绝对。你知道为什么绝大部分情况下不能拆分,而什么情况下还可以拆分呢?

感谢你的收听,欢迎你在评论区给我留言分享你的观点,也欢迎你把这篇文章分享给更多的朋友一起阅读。我们下期再见!