如何编写一个全新的 Git 协议
| 2015-08-21 10:13 收藏: 3
曾几何时,我在持续追踪自己的文件方面遇到一些问题。通常,我忘了自己是否将文件保存在自己的桌面电脑、笔记本电脑或者电话上,或者保存在了云上的什么地方。更有甚者,对非常重要的信息,像密码和Bitcoin的密匙,仅以纯文本邮件的形式将它发送给自己让我芒刺在背。
我需要的是将自己的数据存放一个git仓库里,然后将这个git仓库保存在一个地方。我可以查看以前的版本而且不用提心数据被删除。更最要的是,我已经能熟练地在不同电脑上使用git来上传和下载文件。
但是,如我所言,我并不想简单地上传我的密匙和密码到GitHub或者BitBucket,哪怕是其中的私有仓库。
一个很酷的想法在我脑中升腾:写一个工具来加密我的仓库,然后再将它Push到Backup。遗憾的是,不能像平时那样使用 git push命令,需要使用像这样的命令:
$ encrypted-git push http://example.com/
至少,在我发现git-remote-helpers以前是这样想的。
Git remote helpers
我在网上找到一篇git remote helpers的文档。
原来,如果你运行命令
$ git remote add origin asdf://example.com/repo
$ git push --all origin
Git会首先检查是否内建了asdf协议,当发现没有内建时,它会检查git-remote-asdf是否在PATH(环境变量)里,如果在,它会运行 git-remote-asdf origin asdf://example.com/repo 来处理本次会话。
同样的,你可以运行
$ git clone asdf::http://example.com/repo
很遗憾的是,我发现文档在真正实现一个helper的细节上语焉不详,而这正是我需要的。但是随后,我在Git源码中找到了一个叫git-remote-testgit.sh的脚本,它实现了一个用来测试git远程辅助系统的testgit。 它基本实现了从同样文件系统的本地仓库推送和抓取功能。所以来让git调用 git-remote-asdf origin http://example.com/repo。
git clone testgit::/existing-repository
与
git clone /existing-repository
就一样了。
同样地,你可以透过testgit协议向本地仓库中推送或者从中抓取。
在本文件中,我们将浏览git-remote-testgit的源码并以Go语言实现一个全新的helper分支: git-remote-go。过程中,我将解释源码的意思,以及在实现我自己的remote helper(git-remote-grave)中领悟到的种种.
基础知识
为了后面的章节理解方面,让我们先学习一些术语和基本机制。
当我们运行
$ git remote add myremote go::http://example.com/repo
$ git push myremote master
Git会运行以下命令来实例化一个新的进程
git-remote-go myremote http://example.com/repo
注意:第一个参数是remote name,第二个参数是url.
当你运行
$ git clone go::http://example.com/repo
下一条命令会实例化helper
git-remote-go origin http://example.com/repo
因为远程origin会自动在克隆的仓库中自动创建。
当Git以一个新的进程实例化helper时,它会为 stdin,stdout及stderr通信打开管道。命令被通过stdin送达helper,helper通过stdout响应。任何helper在stderr上的输出被重定向到git的stderr(它可能是一个终端)。
下图说明了这种关系:
我需要说明的最后一点是如何区分本地和远程仓库。通常(但不是每一次),本地仓库是我们运行git的地方,远程仓库是我们需要连接的。
所以在push中,我们从本地仓库发送更改(的地方)到远程仓库。在Fetch中,我们从远程仓库抓取更改(的地方)到本地仓库。在Clone中,我们将远程仓库克隆到本地。
当git运行helper时,git将环境变量GIT_DIR设置为本地仓库的Git目录(比如:local/.git)。
项目开搞
在这篇文章中,我假设已经安装好Go语言,并且使用了环境变量$GOPATH指向一个为go的目录。
让我们以创建目录go/src/git-remote-go开始。这样的话我们就可以通过运行go install来安装我们的插件(假设go/bin在PATH中)。
在意识里面有了这一点后,我们可以编写go/src/git-remote-go/main.go最初的几行代码。
package main
import (
"log"
"os"
)
func Main() (er error) {
if len(os.Args) < 3 {
return fmt.Errorf("Usage: git-remote-go remote-name url")
}
remoteName := os.Args[1]
url := os.Args[2]
}
func main() {
if err := Main(); err != nil {
log.Fatal(err)
}
}
我将Main()分割了开来,因为当我们需要返回错误时错误处理将会变得更容易。这里我们也可以使用defet,因为log.Fatal调用了os.Exit但不调用defer里面的函数。
现在,让我们看下git-remote-testgit文件的最顶部,看下接下来需要做什么。
#!/bin/sh
# Copyright (c) 2012 Felipe Contreras
alias=$1
url=$2
dir="$GIT_DIR/testgit/$alias"
prefix="refs/testgit/$alias"
default_refspec="refs/heads/*:${prefix}/heads/*"
refspec="${GIT_REMOTE_TESTGIT_REFSPEC-$default_refspec}"
test -z "$refspec" && prefix="refs"
GIT_DIR="$url/.git"
export GIT_DIR
force=
mkdir -p "$dir"
if test -z "$GIT_REMOTE_TESTGIT_NO_MARKS"
then
gitmarks="$dir/git.marks"
testgitmarks="$dir/testgit.marks"
test -e "$gitmarks" || >"$gitmarks"
test -e "$testgitmarks" || >"$testgitmarks"
fi
他们称之为alias的变量就是我们所说的remoteName。url则是同样的意义。
下一个声明是:
dir="$GIT_DIR/testgit/$alias"
这里在Git目录下创建了一个命名空间以标识testgit协议和我们正在使用的远程路径。通过这样,testgit下面origin分支下的文件就能与backup分支下面的文件区分开来。
再下面,我们看到这样的声明:
mkdir -p "$dir"
此处确保了本地目录已被创建,如果不存在则创建。
让我们为我们的Go程序添加本地目录的创建。
// Add "path" to the import list
localdir := path.Join(os.Getenv("GIT_DIR"), "go", remoteName)
if err := os.MkdirAll(localdir, 0755); err != nil {
return err
}
紧接着上面的脚本,我们有以下几行:
prefix="refs/testgit/$alias"
default_refspec="refs/heads/*:${prefix}/heads/*"
refspec="${GIT_REMOTE_TESTGIT_REFSPEC-$default_refspec}"
test -z "$refspec" && prefix="refs"
这里快速谈论一下refs。
在git中,refs存放在.git/refs:
.git
└── refs
├── heads
│ └── master
├── remotes
│ ├── gravy
│ └── origin
│ └── master
└── tags
在上面的树中,remotes/origin/master包括了远程origin中mater分支下最近大量的提交。而heads/master则关联你本地mater分支下最近大量的提交。一个ref就像一个指向一次提交的指针。
refspec则可以让我把远程的refs的本地的refs映射起来。在上面的代码中,prefix就是会被远程refs保留的目录。如果远程的名称是原始的,那么远程master分支将会由.git/refs/testgit/origin/master所指定。这样就很基本地为远程的分支创建了指定协议的命名空间。
接下来的这一行则是refspec。这一行
default_refspec="refs/heads/*:${prefix}/heads/*"
可以扩展成
default_refspec="refs/heads/*:refs/testgit/$alias/*"
这意味着远程分支的映射看起来就像把refs/heads/*(这里的*表示任意文本)对应到refs/testgit/$alias/*(这里的*将会被前面的*表示的文本替换)。例如,refs/heads/master将会映射到refs/testgit/origin/master。
基本上来讲,refspec允许testgit添加一个新的分支到自己的树中,例如这样:
.git
└── refs
├── heads
│ └── master
├── remotes
│ └── origin
│ └── master
├── testgit
│ └── origin
│ └── master
└── tags
下一行
refspec="${GIT_REMOTE_TESTGIT_REFSPEC-$default_refspec}"
把$refspec设置成$GIT_REMOTE_TESTGIT_REFSPEC,除非它不存在,否则它会成为$default_refspec。这样的话就能通过testgit测试其他的refspecs了。我们假设都已经成功设置了$default_refspec。
最后,再下一行,
test -z "$refspec" && prefix="refs"
按照我们的理解,看起来像是如果$GIT_REMOTE_TESTGIT_REFSPEC存在却为空时则把$prefix设置成refs。
我们需要自己的refspec,所以需要添加这一行
refspec := fmt.Sprintf("refs/heads/*:refs/go/%s/*", remoteName)
紧随上面的代码,我们看到了
GIT_DIR="$url/.git"
export GIT_DIR
关于$GIT_DIR的另一个事实就是如果它有在环境变量中设置,那么底层的git将会使用环境变量中$GIT_DIR的目录作为它的.git目录,而不再是本地目录的.git。这个命令使得未来全部插件的git命令都能在远程制品库的上下文中执行。
我们把这点转换成
if err := os.Setenv("GIT_DIR", path.Join(url, ".git")); err != nil {
return err
}
当然请记住,那个$dir和我们变量中的localdir依然指向我们正在fetch或push的子目录。
main块里面还有一小段代码
if test -z "$GIT_REMOTE_TESTGIT_NO_MARKS"
then
gitmarks="$dir/git.marks"
testgitmarks="$dir/testgit.marks"
test -e "$gitmarks" || >"$gitmarks"
test -e "$testgitmarks" || >"$testgitmarks"
fi
按我们的理解是,如果$GIT_REMOTE_TESTGIT_NO_MARKS未设置,if语句中的内容将会被执行。
这些标识文件可以纪录像git fast-export和git fast-import这些传递过程中ref和blob的有关信息。有一点是非常重要的,即这些标识在各式各样的插件中都是一样的,所以他们都是保存在localdir中。
这里,$gitmarks关联着我们本地制品库中git写入的标识,$testgitmarks则保存远程处理写入的标识。
下面这两行有点像touch的使用,如果标识文件不存在,则创建一个空的。
test -e "$gitmarks" || >"$gitmarks"
test -e "$testgitmarks" || >"$testgitmarks"
我们自己的程序中需要这些文件,所以让我们以编写一个Touch函数开始。
// Create path as an empty file if it doesn't exist, otherwise do nothing.
// This works by opening a file in exclusive mode; if it already exists,
// an error will be returned rather than truncating it.
func Touch(path string) error {
file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0666)
if os.IsExist(err) {
return nil
} else if err != nil {
return err
}
return file.Close()
}
现在我们可以创建标识文件了。
gitmarks := path.Join(localdir, "git.marks")
gomarks := path.Join(localdir, "go.marks")
if err := Touch(gitmarks); err != nil {
return err
}
if err := Touch(gomarks); err != nil {
return err
}
然后,我遇到的一个问题就是,如果因为某些原因而导致插件失败的话,这些标识文件将会处于残留在一个无效的状态。为了预防这一点,我们可以先保存文件的原始内容,并且如果Main()函数返回一个错误的话我们就重写他们。
// add "io/ioutil" to imports
originalGitmarks, err := ioutil.ReadFile(gitmarks)
if err != nil {
return err
}
originalGomarks, err := ioutil.ReadFile(gomarks)
if err != nil {
return err
}
defer func() {
if er != nil {
ioutil.WriteFile(gitmarks, originalGitmarks, 0666)
ioutil.WriteFile(gomarks, originalGomarks, 0666)
}
}()
最后我们可以从关键命令操作开始。
命令行通过标准输入流stdin传递到插件,也就是每一条命令是以回车结尾和一个字符串。插件则通过标准输出流stdout对命令作出响应;标准错误流stderr则通过管道输出给终端用户。
下面来编写我们自己的命令操作。
// Add "bufio" to import list.
stdinReader := bufio.NewReader(os.Stdin)
for {
// Note that command will include the trailing newline.
command, err := stdinReader.ReadString('\n')
if err != nil {
return err
}
switch {
case command == "capabilities\n":
// ...
case command == "\n":
return nil
default:
return fmt.Errorf("Received unknown command %q", command)
}
}
capabilities 命令
第一条有待实现的命令是capabilities。插件要求能以空行结尾并以行分割的形式输出显示它能提供的命令和它所支持的操作。
echo 'import'
echo 'export'
test -n "$refspec" && echo "refspec $refspec"
if test -n "$gitmarks"
then
echo "*import-marks $gitmarks"
echo "*export-marks $gitmarks"
fi
test -n "$GIT_REMOTE_TESTGIT_SIGNED_TAGS" && echo "signed-tags"
test -n "$GIT_REMOTE_TESTGIT_NO_PRIVATE_UPDATE" && echo "no-private-update"
echo 'option'
echo
上面使用列表中声明了此插件支持import,import和option命令操作。option命令允许git改变我们的插件中冗长的部分。
signed-tags意味着当git为export命令创建了一个快速导入的流时,它将会把--signed-tags=verbatim传递给git-fast-export。
no-private-update则指示着git不需要更新私有的ref当它被成功push后。我未曾看到有需要用到这个特性。
refspec $refspec用于告诉git我们需要使用哪个refspec。
*import-marks $gitmarks和*export-marks $gitmarks意思是git应该保存它生成的标识到gitmarks文件中。*号表示如果git不能识别这几行,它必须失败返回而不是忽略他们。这是因为插件依赖于所保存的标识文件,并且不能和git不支持的版本一起工作。
我们先忽略signed-tags,no-private-update和option,因为它们用于在git-remote-testgit未完成的测试,并且在我们这个例子中也不需要这些。我们可以这样简单地实现上面这些,如:
case command == "capabilities\n":
fmt.Printf("import\n")
fmt.Printf("export\n")
fmt.Printf("refspec %s\n", refspec)
fmt.Printf("*import-marks %s\n", gitmarks)
fmt.Printf("*export-marks %s\n", gitmarks)
fmt.Printf("\n")
list命令
下一个命令是list。这个命令的使用说明并没有包括在capabilities命令输出的使用说明列表中,是因为它通常都是插件所必须支持的。
当插件接收到一个list命令时,它应该打印输出远程制品库上的ref,并每行以$objectname $refname这样的格式用一系列的行来表示,并且最后跟着一行空行。$refname对应着ref的名称,$objectname则是ref指向的内容。$objectname可以是一次提交的哈希,或者用@$refname表示指向另外一个ref,或者是用?表示ref的值不可获得。
git-remote-testgit的实现如下。
git for-each-ref --format='? %(refname)' 'refs/heads/'
head=$(git symbolic-ref HEAD)
echo "@$head HEAD"
echo
记住,$GIT_DIR将触发git for-each-ref在远程制品库的执行,并将会为每一个分支打印一行? $refname,同时还有@$head HEAD,这里的$head即为指向制品库HEAD的ref的名称。
在一个常规的制品库里一般会有两个分支,即master主分支和dev开发分支,这样的话上面的输出可能就像这样
? refs/heads/master
? refs/heads/development
@refs/heads/master HEAD
<blank>
现在让我们自己来写这些。先写一个GitListRefs()函数,因为我们稍候会再次用到。
// Add "os/exec" and "bytes" to the import list.
// Returns a map of refnames to objectnames.
func GitListRefs() (map[string]string, error) {
out, err := exec.Command(
"git", "for-each-ref", "--format=%(objectname) %(refname)",
"refs/heads/",
).Output()
if err != nil {
return nil, err
}
lines := bytes.Split(out, []byte{'\n'})
refs := make(map[string]string, len(lines))
for _, line := range lines {
fields := bytes.Split(line, []byte{' '})
if len(fields) < 2 {
break
}
refs[string(fields[1])] = string(fields[0])
}
return refs, nil
}
现在编写GitSymbolicRef()。
func GitSymbolicRef(name string) (string, error) {
out, err := exec.Command("git", "symbolic-ref", name).Output()
if err != nil {
return "", fmt.Errorf(
"GitSymbolicRef: git symbolic-ref %s: %v", name, out, err)
}
return string(bytes.TrimSpace(out)), nil
}
然后可以像这样来实现list命令。
case command == "list\n":
refs, err := GitListRefs()
if err != nil {
return fmt.Errorf("command list: %v", err)
}
head, err := GitSymbolicRef("HEAD")
if err != nil {
return fmt.Errorf("command list: %v", err)
}
for refname := range refs {
fmt.Printf("? %s\n", refname)
}
fmt.Printf("@%s HEAD\n", head)
fmt.Printf("\n")
import 命令
下一步是git在fetch或clone时会用到的import命令。这个命令实际来源于batch:它把import $refname作为一系列的行并用一个空行结束来发送。当git将此命令发送到辅助插件时,它将以二进制形式执行git fast-import,并且通过管道将标准输出stdout和标准输入stdin绑定起来。换句话说,辅助插件期望能在标准输出stdout上返回一个git fast-export流。
让我们看下git-remote-testgit的实现。
# read all import lines
while true
do
ref="${line#* }"
refs="$refs $ref"
read line
test "${line%% *}" != "import" && break
done
if test -n "$gitmarks"
then
echo "feature import-marks=$gitmarks"
echo "feature export-marks=$gitmarks"
fi
if test -n "$GIT_REMOTE_TESTGIT_FAILURE"
then
echo "feature done"
exit 1
fi
echo "feature done"
git fast-export \
${testgitmarks:+"--import-marks=$testgitmarks"} \
${testgitmarks:+"--export-marks=$testgitmarks"} \
$refs |
sed -e "s#refs/heads/#${prefix}/heads/#g"
echo "done"
最顶部的循环,正如注释所说的,将全部的import $refname命令汇总到一个单一的变量$refs中,而$refs则是以空格分隔的列表。
接下来的,如果脚本正在使用gitmarks文件(假设是这样),将会输出feature import-marks=$gitmarks和feature export-marks=$gitmarks。这里告诉git需要把--import-marks=$gitmarks和--export-marks=$gitmarks传递给git fast-import。
再下一行中,如果出于测试目的设置了$GIT_REMOTE_TESTGIT_FAILURE,插件将会失败。
在那以后,feature done将会输出,暗示着将紧跟输出导出的流内容。
最后,git fast-export在远程制品库被调用,在远程标识上设置指定的标识文件以及$testgitmarks,然后返回我们需要导出的ref列表。
git-fast-export命令的输出内容,通过管道经过将refs/heads/匹配到refs/testgit/$alias/heads/的sed命令。因此在export导出时,我们传递给git的refspec将能很好的使用这个匹配映射。
在导出流后面,紧跟done输出。
我们可以用go来尝试一下。
case strings.HasPrefix(command, "import "):
refs := make([]string, 0)
for {
// Have to make sure to trim the trailing newline.
ref := strings.TrimSpace(strings.TrimPrefix(command, "import "))
refs = append(refs, ref)
command, err = stdinReader.ReadString('\n')
if err != nil {
return err
}
if !strings.HasPrefix(command, "import ") {
break
}
}
fmt.Printf("feature import-marks=%s\n", gitmarks)
fmt.Printf("feature export-marks=%s\n", gitmarks)
fmt.Printf("feature done\n")
args := []string{
"fast-export",
"--import-marks", gomarks,
"--export-marks", gomarks,
"--refspec", refspec}
args = append(args, refs...)
cmd := exec.Command("git", args...)
cmd.Stderr = os.Stderr
cmd.Stdout = os.Stdout
if err := cmd.Run(); err != nil {
return fmt.Errorf("command import: git fast-export: %v", err)
}
fmt.Printf("done\n")
export命令
下一步是export命令。当我们完成了这个命令,我们的辅助插件也就大功告成了。
当我们对远程仓库进行push时,Git 发布了这个export命令。通过标准输入stdin发送这个命令后,git将通过由git fast-export提供的流来追踪,而与git fast-export对应的是可以向远程仓库操纵的git fast-import命令。
if test -n "$GIT_REMOTE_TESTGIT_FAILURE"
then
# consume input so fast-export doesn't get SIGPIPE;
# git would also notice that case, but we want
# to make sure we are exercising the later
# error checks
while read line; do
test "done" = "$line" && break
done
exit 1
fi
before=$(git for-each-ref --format=' %(refname) %(objectname) ')
git fast-import \
${force:+--force} \
${testgitmarks:+"--import-marks=$testgitmarks"} \
${testgitmarks:+"--export-marks=$testgitmarks"} \
--quiet
# figure out which refs were updated
git for-each-ref --format='%(refname) %(objectname)' |
while read ref a
do
case "$before" in
*" $ref $a "*)
continue ;; # unchanged
esac
if test -z "$GIT_REMOTE_TESTGIT_PUSH_ERROR"
then
echo "ok $ref"
else
echo "error $ref $GIT_REMOTE_TESTGIT_PUSH_ERROR"
fi
done
echo
第一行的if语句,和前面的一样,仅仅是为了测试的目的而已。
再下一行更有意思。它创建了一个以空格分割的列表,且这个列表是以$refname $objectname对 来表示我们决定哪些将要在import中被更新ref。
再接下来的命令则相当具有解释性。git fast-import工作于我们接收到的标准输入流,--forece参数表示是否特定,--quiet,以及远程的marks标记文件。
在这之下再次运行了git for-each-ref来检测refs有什么变化。对于这个命令返回的每一个ref,都会检测$refname $objectname对是否出现在$before列表里面。如果是,说明没什么变化并且继续进行下一步。然而如果ref不存这个$before列表中,将会打包输出ok $refname以告知git对应的ref被成功更新了。如果打印error $refname $message则是通知git对应的ref在远程终端导入失败。
最后,打印的一个空行表明导入完毕。
现在我们可以自己编写这些代码了。我们可以使用我们之前定义的GitListRefs()方法。
case command == "export\n":
beforeRefs, err := GitListRefs()
if err != nil {
return fmt.Errorf("command export: collecting before refs: %v", err)
}
cmd := exec.Command("git", "fast-import", "--quiet",
"--import-marks="+gomarks,
"--export-marks="+gomarks)
cmd.Stderr = os.Stderr
cmd.Stdin = os.Stdin
if err := cmd.Run(); err != nil {
return fmt.Errorf("command export: git fast-import: %v", err)
}
afterRefs, err := GitListRefs()
if err != nil {
return fmt.Errorf("command export: collecting after refs: %v", err)
}
for refname, objectname := range afterRefs {
if beforeRefs[refname] != objectname {
fmt.Printf("ok %s\n", refname)
}
}
fmt.Printf("\n")
牛刀小试
执行 go install,应该能够构建和安装 git-remote-go 到 go/bin。
你可以这样来测试验证:首先创建两个空的git仓库,然后在testlocal中commit一个提交,并通过我们新的辅助插件helper把它push到testremote。
$ cd $HOME
$ git init testremote
Initialized empty Git repository in $HOME/testremote/.git/
$ git init testlocal
Initialized empty Git repository in $HOME/testlocal/.git/
$ cd testlocal
$ echo 'Hello, world!' >hello.txt
$ git add hello.txt
$ git commit -m "First commit."
[master (root-commit) 50d3a83] First commit.
1 file changed, 1 insertion(+)
create mode 100644 hello.txt
$ git remote add origin go::$HOME/testremote
$ git push --all origin
To go::$HOME/testremote
* [new branch] master -> master
$ cd ../testremote
$ git checkout master
$ ls
hello.txt
$ cat hello.txt
Hello, world!
git 远程辅助插件的使用
实现接口后,Git 远程辅助插件可以用于其他的源控制(如 felipec/git-remote-hg),或者推送代码到 CouchDBs (peritus/git-remote-couch), 等等其他。你也可以想象更多其他可能的用处。
出于我最初的动机,我写了一个git远程辅助插件git-remote-grave。你可以使用它来push和fetch你文件系统上或者经过HTTP/HTTPS协议的加密档案文档。
$ git remote add usb grave::/media/usb/backup.grave
$ git push --all backup
使用两种压缩技巧,可以让档案文档的大小通常缩小为原来的22%。
如果你想要一个便利的地方去存放你加密后的git仓库,可以访问我创建的这个站点: filegrave.com 。
此文章的讨论交流部分放置在 Hacker News 和 /r/programming。