如何編寫一個全新的 Git 協議
曾幾何時,我在持續追蹤自己的文件方面遇到一些問題。通常,我忘了自己是否將文件保存在自己的桌面電腦、筆記本電腦或者電話上,或者保存在了云上的什么地方。更有甚者,對非常重要的信息,像密碼和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
來讓git調用 git-remote-asdf origin http://example.com/repo.
很遺憾的是,我發現文檔在真正實現一個helper的細節上語焉不詳,而這正是我需要的。但是隨后,我在Git源碼中找到了一個叫git- remote-testgit.sh的腳本,它實現了一個用來測試git遠程輔助系統的testgit。 它基本實現了從同樣文件系統的本地倉庫推送和抓取功能。所以
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 mainimport (
- "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 Contrerasalias=$1url=$2dir="$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_DIRforce=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
- listlocaldir := 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 importsoriginalGitmarks, 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"fitest -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 lineswhile truedo ref="${line#* }"
- refs="$refs $ref"
- read line test "${line%% *}" != "import" && breakdoneif test -n "$gitmarks"then echo "feature import-marks=$gitmarks"
- echo "feature export-marks=$gitmarks"fiif test -n "$GIT_REMOTE_TESTGIT_FAILURE"then echo "feature done"
- exit 1fiecho "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 1fibefore=$(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 updatedgit for-each-ref --format='%(refname) %(objectname)' |while read ref ado 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"
- fidoneecho
***行的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。