Subscribe最新博客(更多

2019-03-17

Go 语言解析 git config

最近做的一个 go 语言的项目需要频繁读写 git config 文件,一些看似现成的解决方案并不能满足需要:

  1. 不考虑调用外部命令 git config,因为在 Windows 平台性能差。
  2. 不考虑 libgit2,因为会给静态编译和发布带来麻烦。

在 github 上找到一个能够解析 git config 文件的项目 goconfig,该项目的代码直接从 Git 项目的 git/config.c 移植过来,可以确保兼容性。但是这个项目只是一个半成品,因为:

  1. 不支持多值配置项。Git 的很多设置实际上是多值配置项,例如:push.pushOptionremote.<name>.fetch 等。
  2. 不支持配置文件嵌套,即不支持通过 include.path 指令包含其他配置文件,而这在我们要开发的应用中至关重要。
  3. 不支持配置文件继承(多级配置)。Git 在读取一个配置项时,会依次读取系统级配置(/etc/gitconfig)、用户全局配置($HOME/.gitconfig)、仓库级配置文件(.git/config)、嵌套的配置文件(include.path 指向的配置文件)。从优先级上看,仓库级配置文件高于全局配置,更高于系统级配置。
  4. 不支持写配置文件。

于是派生了一个项目到 jiangxin/goconfig,实现了上述特性。

增加多值配置特性

实际上 Git 配置项,无论单值(如 user.name),还是多值(如 remote.<name>.fetch),都应该一视同仁当做多值来处理,这样配置文件嵌套、配置文件继承的处理就非常简单了。即:

  1. 每一个配置项(如 user.name)的值是一个字符串数组。
  2. 如果用户将某个配置项视为单值设置,只取数组的最后一项,作为该配置的唯一值。
  3. 如果用户将某个配置项视为多值设置,数组所有内容都是该配置项内容。

为此将 goconfig.go 的返回值由 map[string]string 替换为支持多值配置的自定义类型

type GitConfig map[string]GitConfigKeys
type GitConfigKeys map[string][]string

其中 GitConfig 的索引对应 config 配置文件的小节(section)名称,GitConfigKeys 的索引对应于配置项在小节内的 key。

GitConfig 最核心的方法是 GetAll,其他方法 Get, GetBool, GetInt 等都是基于 GetAll 方法。

参见提交 9e83c31 (Add GitConfig to read boolean, int, multi-values, 2019-02-28)

增加配置文件嵌套特性

配置文件嵌套,涉及到迭代和配置文件的合并。核心方法是 GitConfig 的 Merge 方法。

参见提交 83a00ae (Parse include config files from include.path, 2019-03-05)

增加配置文件继承

配置文件继承和配置文件嵌套非常相似,都是 GitConfig 结构体的 Merge,只不过多了一些文件 IO,以及缓存机制。

参见提交 a033f72 (Add Load() function to read git config from disk, 2019-03-04)

增加配置文件写操作

实际上第一个版本的 GitConfig 结构体定义为 map[string][]string,就可以实现多值配置。为了支持将 GitConfig 结构体回写为文件,对其做了提交修补(git commit –amend)操作。 GitConfig 的 map 索引变成了小节(section)名称。

为了支持写操作所做的第二个改变是为每一个值增加了一个范围(scope),这样在保存 GitConfig 到文件的时候,知道哪些配置来自于系统级(ScopeSystem)、全局级(ScopeGlobal)、 文件嵌套(ScopeGlobal),只将配置文件中的 ScopeSelf 记录到配置文件中。

参见提交:

一个简单的 git-config 实现

作为一个 lib,goconfig 项目的根目录是名为 goconfig 的包,为了演示如何用 goconfig 实现一个完整的 git config 命令的功能,在 cmd/gocongig 目录下写了一个 main 包。

可以用如下命令编译安装这一 goconfig 示例:

go get github.com/jiangxin/goconfig/cmd/goconfig

示例代码参见:cmd/goconfig/main.go

欢迎使用 goconfig 并帮助改进

View Comments
2016-02-29

二分查找捉虫记

1. 问题现象

Git 2.8.0 版本即将发布,今天把本地的 Git 版本升级到 2.8.0-rc0,结果悲剧了。所有使用 HTTP 协议的公司内部 Git 仓库都无法正常访问!

在确认不是网络和公司 Git 服务器问题之后,自然怀疑到了 HTTP 代理。果然清空了 http_proxy 环境变量后,Git 命令工作正常了:

http_proxy= git ls-remote http://server.name/git/repo.git

然而一旦通过 http_proxy=bad_proxy 环境变量设置了一个错误的代理,即便通过 no_proxy=* 期望绕过代理,Git 2.8.0 却无法正常工作:

$ http_proxy=bad_proxy no_proxy=* git ls-remote http://server.name/git/repo.git
fatal: unable to access 'http://server.name/git/repo.git/': Couldn't resolve proxy 'bad_proxy'

可是我记得升级之前 Git 工作是正常的啊!于是在 Git 2.7.0 下进行了尝试。

将版本切换到 v2.7.0,编译安装 Git。(注意一定要安装,而不是执行当前目录下的 Git。这是因为该命令执行过程中会依次调用 git-ls-remotegit-remote-http 命令,而这两个命令是位于安装路径中的。)

$ git checkout v2.7.0
$ make -j8 && make install # 我的工作站是四核CPU,故此使用 -j8 两倍并发执行编译

测试发现 Git 2.7.0 能够通过 no_proxy 变量绕过错误的 http_proxy 环境变量:

$ http_proxy=bad_proxy no_proxy=* git ls-remote http://server.name/git/repo.git
206b4906c197e76fcc63d7a453f9e3aa00dfb3da        HEAD
206b4906c197e76fcc63d7a453f9e3aa00dfb3da        refs/heads/master

显然一定是 v2.7.0v2.8.0-rc0 中间的某个版本引入的 Bug!

2. 二分查找

想必大家都玩过猜数字游戏吧:一个人在1到100的数字中随意选择一个,另外一个人来猜,小孩子总是一个挨着一个地猜, 懂得折半查找的大人总是获胜者。Git 提供的 git bisect 这一命令,就是采用这样的二分查找快速地在提交中定位 Bug, 少则几次,多则十几次就会定位到引入Bug的提交。

  1. 首先执行下面命令启用二分查找。

     $ git bisect start
    
  2. 标记一个好版本。下面的命令使用 tag(v2.7.0)来标记 Git 2.7.0 版本是好版本,换做40位的提交号也行。

     $ git bisect good v2.7.0
    
  3. 标记 Git 2.8.0-rc0 是一个坏版本。注意:马上就是见证奇迹的时刻。

     $ git bisect bad v2.8.0-rc0
     Bisecting: 297 revisions left to test after this (roughly 8 steps)
     [563e38491eaee6e02643a22c9503d4f774d6c5be] Fifth batch for 2.8 cycle
    

    看到了么?当完成对一个好版本和一个坏版本的标记后,Git 切换到一个中间版本(563e384),并告诉我们大概需要8步可以找到元凶。

  4. 在这个版本下执行前面的测试操作:

     $ make -j8 && make install
     $ git --version
     git version 2.7.0.297.g563e384
     $ http_proxy=bad_proxy no_proxy=* git ls-remote http://server.name/git/repo.git
     fatal: unable to access 'http://server.name/git/repo.git/': Couldn't resolve proxy 'bad_proxy'
    
  5. 对这个版本进行标记。

    这是一个坏版本:

     $ git bisect bad
     Bisecting: 126 revisions left to test after this (roughly 7 steps)
     [e572fef9d459497de2bd719747d5625a27c9b41d] Merge branch 'ep/shell-command-substitution-style'
    

我们可以机械地重复上面4、5的步骤,直到最终定位。但是人工操作很容易出错。如果对版本标记错了,把 good 写成了 bad 或者相反, 就要执行 git bisect reset 重来。(小窍门:git bisect log 可以显示 git bisect 标记操作日志)

于是决定剩下的二分查找使用脚本来完成。

3. 自动化的二分查找

Git 二分查找允许提供一个测试脚本,Git 会根据这个测试脚本的返回值,决定如何来标记提交:

  • 返回值为 0:这个提交是一个好提交。
  • 返回值为 125:这个提交无法测试(例如编译不过去),忽略这个提交。
  • 返回值为 1-127(125除外):这个提交是一个坏提交。
  • 其他返回值:二分查找出错,终止二分查找操作。

那么就我们先来看看 git ls-remote 的返回值:

  • 正确执行的返回值是 0:

      $ http_proxy= git ls-remote http://server.name/git/repo.git
      206b4906c197e76fcc63d7a453f9e3aa00dfb3da        HEAD
      206b4906c197e76fcc63d7a453f9e3aa00dfb3da        refs/heads/master
      $ echo $?
      0
    
  • 错误执行的返回值是 128!

      $ http_proxy=bad_proxy git ls-remote http://server.name/git/repo.git
      fatal: unable to access 'http://server.name/git/repo.git/': Couldn't resolve proxy 'bad_proxy'
      $ echo $?
      128
    

于是创建一个测试脚本 git-proxy-bug-test.sh,内容如下:

#!/bin/sh

make -j8 && make install && \
git --version && \
http_proxy=bad_proxy no_proxy=* \
git ls-remote http://server.name/git/repo.git

case $? in
0)
    exit 0
    ;;
128)
    exit 1
    ;;
*)
    exit 128
    ;;
esac

然后敲下如下命令,开始自动执行二分查找:

$ git bisect run sh git-proxy-bug-test.sh

自动化查找过程可能需要几分钟,站起来走走,休息一下眼睛。再回到座位,最终的定位结果就展现在了眼前:

372370f1675c2b935fb703665358dd5567641107 is the first bad commit
commit 372370f1675c2b935fb703665358dd5567641107
Author: Knut Franke <k.franke@science-computing.de>
Date:   Tue Jan 26 13:02:48 2016 +0000

    http: use credential API to handle proxy authentication

    Currently, the only way to pass proxy credentials to curl is by including them
    in the proxy URL. Usually, this means they will end up on disk unencrypted, one
    way or another (by inclusion in ~/.gitconfig, shell profile or history). Since
    proxy authentication often uses a domain user, credentials can be security
    sensitive; therefore, a safer way of passing credentials is desirable.

    If the configured proxy contains a username but not a password, query the
    credential API for one. Also, make sure we approve/reject proxy credentials
    properly.

    For consistency reasons, add parsing of http_proxy/https_proxy/all_proxy
    environment variables, which would otherwise be evaluated as a fallback by curl.
    Without this, we would have different semantics for git configuration and
    environment variables.

    Helped-by: Junio C Hamano <gitster@pobox.com>
    Helped-by: Eric Sunshine <sunshine@sunshineco.com>
    Helped-by: Elia Pinto <gitter.spiros@gmail.com>
    Signed-off-by: Knut Franke <k.franke@science-computing.de>
    Signed-off-by: Elia Pinto <gitter.spiros@gmail.com>
    Signed-off-by: Junio C Hamano <gitster@pobox.com>

:040000 040000 de69688dd93e4466c11726157bd2f93e47e67330 d19d021e8d1c2a296b521414112be0966bd9f09a M      Documentation
:100644 100644 f46bfc43f9e5e8073563be853744262a1bb4c5d6 dfc53c1e2554e76126459d6cb1f098facac28593 M      http.c
:100644 100644 4f97b60b5c8abdf5ab0610382a6d6fa289df2605 f83cfa686823728587b2a803c3e84a8cd4669220 M      http.h
二分查找运行成功

4. 解决问题

既然我们知道引入 Bug 的提交,让我们看看这个提交:

$ git show --oneline --stat 372370f1675c2b935fb703665358dd5567641107
372370f http: use credential API to handle proxy authentication
 Documentation/config.txt | 10 +++++--
 http.c                   | 77 ++++++++++++++++++++++++++++++++++++++++++++++++
 http.h                   |  1 +
 3 files changed, 85 insertions(+), 3 deletions(-)

相比很多人一个提交动辄改动几百、几千行的代码,这个提交的改动算得上简短了。小提交的好处就是易于阅读、易于问题定位、易于回退。

最终参照上面定位到的问题提交,我的 Bugfix 如下(为了下面的一节叙述方便,给代码补丁增加了行号):

01  diff --git a/http.c b/http.c
02  index 1d5e3bb..69da445 100644
03  --- a/http.c
04  +++ b/http.c
05  @@ -70,6 +70,7 @@ static long curl_low_speed_limit = -1;
06   static long curl_low_speed_time = -1;
07   static int curl_ftp_no_epsv;
08   static const char *curl_http_proxy;
09  +static const char *curl_no_proxy;
10   static const char *http_proxy_authmethod;
12   static struct {
13   	const char *name;
13  @@ -624,6 +625,11 @@ static CURL *get_curl_handle(void)
15   		}
16
17   		curl_easy_setopt(result, CURLOPT_PROXY, proxy_auth.host);
18  +#if LIBCURL_VERSION_NUM >= 0x071304
19  +		var_override(&curl_no_proxy, getenv("NO_PROXY"));
20  +		var_override(&curl_no_proxy, getenv("no_proxy"));
21  +		curl_easy_setopt(result, CURLOPT_NOPROXY, curl_no_proxy);
22  +#endif
23   	}
24   	init_curl_proxy_auth(result);
25
26  --
27  2.8.0.rc0

5. 写提交说明

这个提交是要贡献给 Git 上游的,评审者可能会问我如下问题:

  1. Bug 的现象是什么?

    “系统的 no_proxy 变量不起作用,git 可能无法访问 http 协议的仓库。”

  2. 从什么版本引入这个 Bug?

    “我们定位到的这个提交引入的 Bug。之所以会引入这个 Bug,是因为这个提交读取了 http_proxy 等环境变量, 自动通过 git-credential 获取的信息补齐 http_proxy 的缺失的代理认证口令,并显示设置 libcurl 的参数。”

  3. 之前的版本为什么没有出现这个问题?什么条件下会出现?

    “之前的版本也会出现问题,但是只有在用户主动设置了 http.proxy 配置变量才会出现。 用户很少会去设置 http.proxy 配置变量,而通常是使用 http_proxy 环境变量。”

  4. 你是如何解决的?你的解决方案是否最佳?

    “读取 no_proxy 环境变量,并为 libcurl 配置相应参数。因为 libcurl 只在 7.19.4 之后才引入 CURLOPT_NOPROXY,因此需要添加条件编译。”

实际上,前面的 Bugfix 原本是没有那个条件编译的。即补丁的第18行、22行一开始是没有的, 在回答第4个问题的时候,我仔细查看了 libcurl API, 发现只有在 7.19.4 版本之后,才支持 CURLOPT_NOPROXY 参数,因此如果不添加这个编译条件, 在特定的平台可能会导致 Git 无法编译通过。

下面就是最终的提交说明:

http: honor no_http env variable to bypass proxy

Curl and its families honor several proxy related environment variables:

* http_proxy and https_proxy define proxy for http/https connections.
* no_proxy (a comma separated hosts) defines hosts bypass the proxy.

This command will bypass the bad-proxy and connect to the host directly:

    no_proxy=* https_proxy=http://bad-proxy/ \
    curl -sk https://google.com/

Before commit 372370f (http: use credential API to handle proxy auth...),
Environment variable "no_proxy" will take effect if the config variable
"http.proxy" is not set.  So the following comamnd won't fail if not
behind a firewall.

    no_proxy=* https_proxy=http://bad-proxy/ \
    git ls-remote https://github.com/git/git

But commit 372370f not only read git config variable "http.proxy", but
also read "http_proxy" and "https_proxy" environment variables, and set
the curl option using:

    curl_easy_setopt(result, CURLOPT_PROXY, proxy_auth.host);

This caused "no_proxy" environment variable not working any more.

Set extra curl option "CURLOPT_NOPROXY" will fix this.

Signed-off-by: Jiang Xin <xin.jiang@huawei.com>

6. 贡献给上游

Git 项目本身是通过邮件列表参与代码贡献的,基本的操作流程是将代码转换为补丁文件,然后邮件发送。 基本上就是两条命令:git format-patchgit send-email

下面的链接就是 Git 社区关于我这个提交的讨论。Junio已经确认这个提交是 2.8.0 的一个 regression,相信会合入2.8.0的发布版。

View Comments
2015-12-23

做一个有品位的程序员

——“能够写出漂亮代码的程序员就是有品味的程序员么?”

——“还不够。品味来自于每一个细节,有品位的程序员会把每一次提交做小、做对、做好,尽量做到整个开发的过程的无可挑剔,这样才够逼格,才可以称为有品位。”

熟练使用 Git,会让程序员更有品味。

提交做小

写小提交就是将问题解耦:“Do one thing and do it well”。开源项目的提交通常都很小,每个提交只修改一个到几个文件,每次只修改几行到几十行。 找一个你熟悉的开源项目,在仓库中执行下面的命令,可以很容易地统计出来每个提交的修改量。

$ git log --no-merges --pretty="" --shortstat
2 files changed, 25 insertions(+), 4 deletions(-)
1 file changed, 4 insertions(+), 12 deletions(-)
2 files changed, 30 insertions(+), 1 deletion(-)
3 files changed, 15 insertions(+), 5 deletions(-)

而我看到的很多公司内外的项目则不是这样,动辄成百上千的文件修改,一次改动成千上万行源代码。试问这样的提交能拿来给人看么?

可是在开发过程中,程序员一旦进入状态,往往才思如泉涌,写出一个大提交。比如我又一次向 Git 贡献代码时, 提交还不算太大,就被 Git 的维护者 Junio 吐槽,要我拆分提交,便于评审:

TODO

那么如何将提交拆分为若干个小提交呢?

拆分当前提交(松耦合)

先以拆分最新的提交为例,可以如下操作:

  1. 将当前提交撤销,重置到上一次提交。撤销提交的改动保留在工作区中。

     $ git reset HEAD^
    
  2. 通过补丁块拣选方式选择要提交的修改。Git 会逐一显示工作区更改,如果确认此处改动要提交,输入“y“。

     $ git add -p
    
  3. 以撤销提交的提交说明为蓝本,撰写新的提交。

     $ git commit -e -C HEAD@{1}
    
  4. 对于工作区其余的修改进行提交,完成一个提交拆分为两个的操作。

     $ git add -u
     $ git commit
    

拆分当前提交(紧耦合)

如果要拆分的提交,不同的实现逻辑耦合在一起,难以通过补丁块拣选(git add -p)的方式修改提交,怎么办?这时可以 直接编辑文件,删除要剥离出此次提交的修改,然后执行:

$ git commit --amend

然后执行下面的命令,还原原有的文件修改,然后再提交。如下:

$ git checkout HEAD@{1} -- .
$ git commit

同样完成了一个提交拆分为两个提交的操作。

拆分历史某个提交

如果要拆分的是历史提交(如提交 54321),而非当前提交,则可以执行交互式变基(git rebase -i),如下:

$ git rebase -i 54321^

Git 会自动将参与变基的提交写在一个动作文件中,还会自动打开编辑器(比如 vi 编辑器)。在编辑器中显示内容示例如下:

pick 54321 要拆分的提交
pick ...   其他参与变基的提交

将要拆分的提交 54321 前面的关键字 pick 修改为 edit,保存并退出。变基操作随即开始执行。

首先会在提交 54321 处停下来,这时要拆分的提交成为了当前提交,参照前面“拆分当前提交”的方法对提交 54321 进行拆分。拆分结束再执行 git rebase --continue 完成整个变基操作。

提交做对

“好的文章不是写出来的,而是改出来的。” 代码提交也是如此。

程序员写完代码,往往迫不及待地敲下:git commit,然后发现提交中少了一个文件,或者提交了多余的文件,或者发现提交中包含错误无法编译,或者提交说明中出现了错别字。

Git 提供了一个修改当前提交的快捷命令:git commit --amend,相信很多人都用过,不再赘述。

如果你发现错误出现在上一个提交或其他历史提交中怎么办呢?我有一个小窍门,在《Git权威指南》里我没有写到哦。

比如发现历史提交 54321 中包含错误,直接在当前工作区中针对这个错误进行修改,然后执行下面命令。

git commit --fixup 54321

你会发现使用了 --fixup 参数的提交命令,不再询问你提交说明怎么写,而是直接把错误提交 54321 的提交说明的第一行拿来,在前面增加一个前缀“fixup!”,如下:

fixup! 原提交说明

如果一次没有改对,还可以再接着改,甚至你还可以针对这个修正提交进行 fixup,产生如下格式的提交说明:

fixup! fixup! 原提交说明

当开发工作完成后,待推送/评审的提交中出现大量的包含“fixup!”前缀的提交该如何处理呢?

如果你执行过一次下面的命令,即针对错误提交 54321 及其后面所有提交执行交互式变基(注意其中的 --autosquash 参数),你就会惊叹 Git 设计的是这么巧妙:

$ git rebase -i --autosquash 54321^

交互式变基弹出的编辑器内自动对提交进行排序,将提交 54321 连同它的所有修正提交压缩为一个提交。

对于“提交做对”,很多开源项目还通过单元测试用例提供保障。对于这样的项目,在提交代码时往往要求提供相应的测试用例。 Git 项目本身就对测试用例有着很高的要求,其测试框架也非常有意思。我曾经针对Git的单元测试框架写过博客,参见: 复用 git.git 测试框架

提交做好

仅仅做到提交做小、提交做对,往往还不够,还要通过撰写详细的提交说明让评审者信服,这样才能够让提交尽快通过评审合入项目仓库中。

例如今年7月份在华为公司内部的 Git 服务器上发现一个异常,最终将问题定位到 Git 工具本身。整个代码改动只有区区一行:

你能猜到提交说明写了多少么?写了20多行!

receive-pack: crash when checking with non-exist HEAD

If HEAD of a repository points to a conflict reference, such as:

* There exist a reference named 'refs/heads/jx/feature1', but HEAD
  points to 'refs/heads/jx', or

* There exist a reference named 'refs/heads/feature', but HEAD points
  to 'refs/heads/feature/bad'.

When we push to delete a reference for this repo, such as:

        git push /path/to/bad-head-repo.git :some/good/reference

The git-receive-pack process will crash.

This is because if HEAD points to a conflict reference, the function
`resolve_refdup("HEAD", ...)` does not return a valid reference name,
but a null buffer.  Later matching the delete reference against the null
buffer will cause git-receive-pack crash.

Signed-off-by: Jiang Xin <worldhello.net@gmail.com>
Signed-off-by: Junio C Hamano <gitster@pobox.com>

Git 对于提交说明的格式有着如下约定俗成的规定:

  • 提交主题

    提交说明第一行是提交主题,是整个提交的概要性描述。可以在提交主题中添加所更改的模块名称作为前缀(如:receive-pack:)。 提交主题(即提交说明的第一行)尽量保持在50字节以内(Gerrit 的commit_log检查插件似乎有着稍微宽泛一些的要求)。 这是因为对于像 Linux、Git 这样的开源项目,是以邮件列表作为代码评审的平台,提交主题要作为邮件的标题,而邮件标题本身有长度上的限制。

  • 提交主题后的空行

    必须要在提交说明的第一行和后续的提交说明中间留一个空行!如果没有这个空行,很多 Git 客户端会将连续几行的提交说明合在一起作为提交描述。这样显然太糟了。

  • 提交说明主体

    提交主题之外的提交说明也有长度的限制,最好以72字节为限,超过则断行。因为 GitHub 在显示提交说明时支持 Markdown 语法, 所以作为一个有品位的程序员学些 Markdown 的语法,让你的提交说明的可读性变得更强吧。

    我总结过一个 Markdown 和其他文本标记语言的语法说明,可供参考:

  • 签名区

    在提交说明最后是签名区。签名区可以看出这个提交的参与者、评审记录等等。

最后,让我们一起学习成为一名有品位的程序员吧。并依靠你对代码的品味,高质量严要求,守护你的项目吧。

View Comments

我的书

联系方式