曾幾何時,我在持續追蹤自己的檔案方面遇到一些問題。通常,我忘了自己是否将檔案儲存在自己的桌面電腦、筆記本電腦或者電話上,或者儲存在了雲上的什麼地方。更有甚者,對非常重要的資訊,像密碼和bitcoin的密匙,僅以純文字郵件的形式将它發送給自己讓我芒刺在背。
我需要的是将自己的資料存放一個git倉庫裡,然後将這個git倉庫儲存在一個地方。我可以檢視以前的版本而且不用提心資料被删除。更最要的是,我已經能熟練地在不同電腦上使用git來上傳和下載下傳檔案。
但是,如我所言,我并不想簡單地上傳我的密匙和密碼到github或者bitbucket,哪怕是其中的私有倉庫。

一個很酷的想法在我腦中升騰:寫一個工具來加密我的倉庫,然後再将它push到backup。遺憾的是,不能像平時那樣使用 git push指令,需要使用像這樣的指令:
<code>$ encrypted-git push http://example.com/</code>
至少,在我發現git-remote-helpers以前是這樣想的。
<a target="_blank"></a>
我在網上找到一篇git remote helpers的文檔。
原來,如果你運作指令
<code>$ git remote add origin asdf://example.com/repo</code>
<code>$ git push --all origin</code>
git會首先檢查是否内建了asdf協定,當發現沒有内建時,它會檢查git-remote-asdf是否在path(環境變量)裡,如果在,它會運作 git-remote-asdf origin asdf://example.com/repo 來處理本次會話。
同樣的,你可以運作
<code>$ git clone asdf::http://example.com/repo</code>
<code>git clone testgit::/existing-repository</code>
與
<code>git clone /existing-repository</code>
就一樣了。
同樣地,你可以透過testgit協定向本地倉庫中推送或者從中抓取。
在本檔案中,我們将浏覽git-remote-testgit的源碼并以go語言實作一個全新的helper分支: git-remote-go。過程中,我将解釋源碼的意思,以及在實作我自己的remote helper(git-remote-grave)中領悟到的種種.
為了後面的章節了解方面,讓我們先學習一些術語和基本機制。
當我們運作
<code>$ git remote add myremote go::http://example.com/repo</code>
<code>$ git push myremote master</code>
git會運作以下指令來執行個體化一個新的程序
<code>git-remote-go myremote http://example.com/repo</code>
注意:第一個參數是remote name,第二個參數是url.
當你運作
<code>$ git clone go::http://example.com/repo</code>
下一條指令會執行個體化helper
<code>git-remote-go origin http://example.com/repo</code>
因為遠端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/src/git-remote-go開始。這樣的話我們就可以通過運作go install來安裝我們的插件(假設go/bin在path中)。
在意識裡面有了這一點後,我們可以編寫go/src/git-remote-go/main.go最初的幾行代碼。
<code>package main</code>
<code></code>
<code>import (</code>
<code>"log"</code>
<code>"os"</code>
<code>)</code>
<code>func main() (er error) {</code>
<code>if len(os.args) < 3 {</code>
<code>return fmt.errorf("usage: git-remote-go remote-name url")</code>
<code>}</code>
<code>remotename := os.args[1]</code>
<code>url := os.args[2]</code>
<code>func main() {</code>
<code>if err := main(); err != nil {</code>
<code>log.fatal(err)</code>
我将main()分割了開來,因為當我們需要傳回錯誤時錯誤處理将會變得更容易。這裡我們也可以使用defet,因為log.fatal調用了os.exit但不調用defer裡面的函數。
現在,讓我們看下git-remote-testgit檔案的最頂部,看下接下來需要做什麼。
<code>#!/bin/sh</code>
<code># copyright (c) 2012 felipe contreras</code>
<code>alias=$1</code>
<code>url=$2</code>
<code>dir="$git_dir/testgit/$alias"</code>
<code>prefix="refs/testgit/$alias"</code>
<code>default_refspec="refs/heads/*:${prefix}/heads/*"</code>
<code>refspec="${git_remote_testgit_refspec-$default_refspec}"</code>
<code>test -z "$refspec" && prefix="refs"</code>
<code>git_dir="$url/.git"</code>
<code>export git_dir</code>
<code>force=</code>
<code>mkdir -p "$dir"</code>
<code>if test -z "$git_remote_testgit_no_marks"</code>
<code>then</code>
<code>gitmarks="$dir/git.marks"</code>
<code>testgitmarks="$dir/testgit.marks"</code>
<code>test -e "$gitmarks" || >"$gitmarks"</code>
<code>test -e "$testgitmarks" || >"$testgitmarks"</code>
<code>fi</code>
他們稱之為alias的變量就是我們所說的remotename。url則是同樣的意義。
下一個聲明是:
這裡在git目錄下建立了一個命名空間以辨別testgit協定和我們正在使用的遠端路徑。通過這樣,testgit下面origin分支下的檔案就能與backup分支下面的檔案區分開來。
再下面,我們看到這樣的聲明:
此處確定了本地目錄已被建立,如果不存在則建立。
讓我們為我們的go程式添加本地目錄的建立。
<code>// add "path" to the import list</code>
<code>localdir := path.join(os.getenv("git_dir"), "go", remotename)</code>
<code>if err := os.mkdirall(localdir, 0755); err != nil {</code>
<code>return err</code>
緊接着上面的腳本,我們有以下幾行:
這裡快速談論一下refs。
在git中,refs存放在.git/refs:
<code>.git</code>
<code>└── refs</code>
<code>├── heads</code>
<code>│ └── master</code>
<code>├── remotes</code>
<code>│ ├── gravy</code>
<code>│ └── origin</code>
<code>│ └── master</code>
<code>└── tags</code>
在上面的樹中,remotes/origin/master包括了遠端origin中mater分支下最近大量的送出。而heads/master則關聯你本地mater分支下最近大量的送出。一個ref就像一個指向一次送出的指針。
refspec則可以讓我把遠端的refs的本地的refs映射起來。在上面的代碼中,prefix就是會被遠端refs保留的目錄。如果遠端的名稱是原始的,那麼遠端master分支将會由.git/refs/testgit/origin/master所指定。這樣就很基本地為遠端的分支建立了指定協定的命名空間。
接下來的這一行則是refspec。這一行
可以擴充成
<code>default_refspec="refs/heads/*:refs/testgit/$alias/*"</code>
這意味着遠端分支的映射看起來就像把refs/heads/*(這裡的*表示任意文本)對應到refs/testgit/$alias/*(這裡的*将會被前面的*表示的文本替換)。例如,refs/heads/master将會映射到refs/testgit/origin/master。
基本上來講,refspec允許testgit添加一個新的分支到自己的樹中,例如這樣:
<code>├── testgit</code>
下一行
把$refspec設定成$git_remote_testgit_refspec,除非它不存在,否則它會成為$default_refspec。這樣的話就能通過testgit測試其他的refspecs了。我們假設都已經成功設定了$default_refspec。
最後,再下一行,
按照我們的了解,看起來像是如果$git_remote_testgit_refspec存在卻為空時則把$prefix設定成refs。
我們需要自己的refspec,是以需要添加這一行
<code>refspec := fmt.sprintf("refs/heads/*:refs/go/%s/*", remotename)</code>
緊随上面的代碼,我們看到了
關于$git_dir的另一個事實就是如果它有在環境變量中設定,那麼底層的git将會使用環境變量中$git_dir的目錄作為它的.git目錄,而不再是本地目錄的.git。這個指令使得未來全部插件的git指令都能在遠端制品庫的上下文中執行。
我們把這點轉換成
<code>if err := os.setenv("git_dir", path.join(url, ".git")); err != nil {</code>
當然請記住,那個$dir和我們變量中的localdir依然指向我們正在fetch或push的子目錄。
main塊裡面還有一小段代碼
按我們的了解是,如果$git_remote_testgit_no_marks未設定,if語句中的内容将會被執行。
這些辨別檔案可以紀錄像git fast-export和git fast-import這些傳遞過程中ref和blob的有關資訊。有一點是非常重要的,即這些辨別在各式各樣的插件中都是一樣的,是以他們都是儲存在localdir中。
這裡,$gitmarks關聯着我們本地制品庫中git寫入的辨別,$testgitmarks則儲存遠端處理寫入的辨別。
下面這兩行有點像touch的使用,如果辨別檔案不存在,則建立一個空的。
我們自己的程式中需要這些檔案,是以讓我們以編寫一個touch函數開始。
<code>// create path as an empty file if it doesn't exist, otherwise do nothing.</code>
<code>// this works by opening a file in exclusive mode; if it already exists,</code>
<code>// an error will be returned rather than truncating it.</code>
<code>func touch(path string) error {</code>
<code>file, err := os.openfile(path, os.o_wronly|os.o_create|os.o_excl, 0666)</code>
<code>if os.isexist(err) {</code>
<code>return nil</code>
<code>} else if err != nil {</code>
<code>return file.close()</code>
現在我們可以建立辨別檔案了。
<code>gitmarks := path.join(localdir, "git.marks")</code>
<code>gomarks := path.join(localdir, "go.marks")</code>
<code>if err := touch(gitmarks); err != nil {</code>
<code>if err := touch(gomarks); err != nil {</code>
然後,我遇到的一個問題就是,如果因為某些原因而導緻插件失敗的話,這些辨別檔案将會處于殘留在一個無效的狀态。為了預防這一點,我們可以先儲存檔案的原始内容,并且如果main()函數傳回一個錯誤的話我們就重寫他們。
<code>// add "io/ioutil" to imports</code>
<code>originalgitmarks, err := ioutil.readfile(gitmarks)</code>
<code>if err != nil {</code>
<code>originalgomarks, err := ioutil.readfile(gomarks)</code>
<code>defer func() {</code>
<code>if er != nil {</code>
<code>ioutil.writefile(gitmarks, originalgitmarks, 0666)</code>
<code>ioutil.writefile(gomarks, originalgomarks, 0666)</code>
<code>}()</code>
最後我們可以從關鍵指令操作開始。
指令行通過标準輸入流stdin傳遞到插件,也就是每一條指令是以回車結尾和一個字元串。插件則通過标準輸出流stdout對指令作出響應;标準錯誤流stderr則通過管道輸出給終端使用者。
下面來編寫我們自己的指令操作。
<code>// add "bufio" to import list.</code>
<code>stdinreader := bufio.newreader(os.stdin)</code>
<code>for {</code>
<code>// note that command will include the trailing newline.</code>
<code>command, err := stdinreader.readstring('\n')</code>
<code>switch {</code>
<code>case command == "capabilities\n":</code>
<code>// ...</code>
<code>case command == "\n":</code>
<code>default:</code>
<code>return fmt.errorf("received unknown command %q", command)</code>
第一條有待實作的指令是capabilities。插件要求能以空行結尾并以行分割的形式輸出顯示它能提供的指令和它所支援的操作。
<code>echo 'import'</code>
<code>echo 'export'</code>
<code>test -n "$refspec" && echo "refspec $refspec"</code>
<code>if test -n "$gitmarks"</code>
<code>echo "*import-marks $gitmarks"</code>
<code>echo "*export-marks $gitmarks"</code>
<code>test -n "$git_remote_testgit_signed_tags" && echo "signed-tags"</code>
<code>test -n "$git_remote_testgit_no_private_update" && echo "no-private-update"</code>
<code>echo 'option'</code>
<code>echo</code>
上面使用清單中聲明了此插件支援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未完成的測試,并且在我們這個例子中也不需要這些。我們可以這樣簡單地實作上面這些,如:
<code>fmt.printf("import\n")</code>
<code>fmt.printf("export\n")</code>
<code>fmt.printf("refspec %s\n", refspec)</code>
<code>fmt.printf("*import-marks %s\n", gitmarks)</code>
<code>fmt.printf("*export-marks %s\n", gitmarks)</code>
<code>fmt.printf("\n")</code>
下一個指令是list。這個指令的使用說明并沒有包括在capabilities指令輸出的使用說明清單中,是因為它通常都是插件所必須支援的。
當插件接收到一個list指令時,它應該列印輸出遠端制品庫上的ref,并每行以$objectname $refname這樣的格式用一系列的行來表示,并且最後跟着一行空行。$refname對應着ref的名稱,$objectname則是ref指向的内容。$objectname可以是一次送出的哈希,或者用@$refname表示指向另外一個ref,或者是用?表示ref的值不可獲得。
git-remote-testgit的實作如下。
<code>git for-each-ref --format='? %(refname)' 'refs/heads/'</code>
<code>head=$(git symbolic-ref head)</code>
<code>echo "@$head head"</code>
記住,$git_dir将觸發git for-each-ref在遠端制品庫的執行,并将會為每一個分支列印一行? $refname,同時還有@$head head,這裡的$head即為指向制品庫head的ref的名稱。
在一個正常的制品庫裡一般會有兩個分支,即master主分支和dev開發分支,這樣的話上面的輸出可能就像這樣
<code>? refs/heads/master</code>
<code>? refs/heads/development</code>
<code>@refs/heads/master head</code>
<code><blank></code>
現在讓我們自己來寫這些。先寫一個gitlistrefs()函數,因為我們稍候會再次用到。
<code>// add "os/exec" and "bytes" to the import list.</code>
<code>// returns a map of refnames to objectnames.</code>
<code>func gitlistrefs() (map[string]string, error) {</code>
<code>out, err := exec.command(</code>
<code>"git", "for-each-ref", "--format=%(objectname) %(refname)",</code>
<code>"refs/heads/",</code>
<code>).output()</code>
<code>return nil, err</code>
<code>lines := bytes.split(out, []byte{'\n'})</code>
<code>refs := make(map[string]string, len(lines))</code>
<code>for _, line := range lines {</code>
<code>fields := bytes.split(line, []byte{' '})</code>
<code>if len(fields) < 2 {</code>
<code>break</code>
<code>refs[string(fields[1])] = string(fields[0])</code>
<code>return refs, nil</code>
現在編寫gitsymbolicref()。
<code>func gitsymbolicref(name string) (string, error) {</code>
<code>out, err := exec.command("git", "symbolic-ref", name).output()</code>
<code>return "", fmt.errorf(</code>
<code>"gitsymbolicref: git symbolic-ref %s: %v", name, out, err)</code>
<code>return string(bytes.trimspace(out)), nil</code>
然後可以像這樣來實作list指令。
<code>case command == "list\n":</code>
<code>refs, err := gitlistrefs()</code>
<code>return fmt.errorf("command list: %v", err)</code>
<code>head, err := gitsymbolicref("head")</code>
<code>for refname := range refs {</code>
<code>fmt.printf("? %s\n", refname)</code>
<code>fmt.printf("@%s head\n", head)</code>
下一步是git在fetch或clone時會用到的import指令。這個指令實際來源于batch:它把import $refname作為一系列的行并用一個空行結束來發送。當git将此指令發送到輔助插件時,它将以二進制形式執行git fast-import,并且通過管道将标準輸出stdout和标準輸入stdin綁定起來。換句話說,輔助插件期望能在标準輸出stdout上傳回一個git fast-export流。
讓我們看下git-remote-testgit的實作。
<code># read all import lines</code>
<code>while true</code>
<code>do</code>
<code>ref="${line#* }"</code>
<code>refs="$refs $ref"</code>
<code>read line</code>
<code>test "${line%% *}" != "import" && break</code>
<code>done</code>
<code>echo "feature import-marks=$gitmarks"</code>
<code>echo "feature export-marks=$gitmarks"</code>
<code>if test -n "$git_remote_testgit_failure"</code>
<code>echo "feature done"</code>
<code>exit 1</code>
<code>git fast-export \</code>
<code>${testgitmarks:+"--import-marks=$testgitmarks"} \</code>
<code>${testgitmarks:+"--export-marks=$testgitmarks"} \</code>
<code>$refs |</code>
<code>sed -e "s#refs/heads/#${prefix}/heads/#g"</code>
<code>echo "done"</code>
最頂部的循環,正如注釋所說的,将全部的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來嘗試一下。
<code>case strings.hasprefix(command, "import "):</code>
<code>refs := make([]string, 0)</code>
<code>// have to make sure to trim the trailing newline.</code>
<code>ref := strings.trimspace(strings.trimprefix(command, "import "))</code>
<code>refs = append(refs, ref)</code>
<code>command, err = stdinreader.readstring('\n')</code>
<code>if !strings.hasprefix(command, "import ") {</code>
<code>fmt.printf("feature import-marks=%s\n", gitmarks)</code>
<code>fmt.printf("feature export-marks=%s\n", gitmarks)</code>
<code>fmt.printf("feature done\n")</code>
<code>args := []string{</code>
<code>"fast-export",</code>
<code>"--import-marks", gomarks,</code>
<code>"--export-marks", gomarks,</code>
<code>"--refspec", refspec}</code>
<code>args = append(args, refs...)</code>
<code>cmd := exec.command("git", args...)</code>
<code>cmd.stderr = os.stderr</code>
<code>cmd.stdout = os.stdout</code>
<code>if err := cmd.run(); err != nil {</code>
<code>return fmt.errorf("command import: git fast-export: %v", err)</code>
<code>fmt.printf("done\n")</code>
下一步是export指令。當我們完成了這個指令,我們的輔助插件也就大功告成了。
當我們對遠端倉庫進行push時,git 釋出了這個export指令。通過标準輸入stdin發送這個指令後,git将通過由git fast-export提供的流來追蹤,而與git fast-export對應的是可以向遠端倉庫操縱的git fast-import指令。
<code># consume input so fast-export doesn't get sigpipe;</code>
<code># git would also notice that case, but we want</code>
<code># to make sure we are exercising the later</code>
<code># error checks</code>
<code>while read line; do</code>
<code>test "done" = "$line" && break</code>
<code>before=$(git for-each-ref --format=' %(refname) %(objectname) ')</code>
<code>git fast-import \</code>
<code>${force:+--force} \</code>
<code>--quiet</code>
<code># figure out which refs were updated</code>
<code>git for-each-ref --format='%(refname) %(objectname)' |</code>
<code>while read ref a</code>
<code>case "$before" in</code>
<code>*" $ref $a "*)</code>
<code>continue ;; # unchanged</code>
<code>esac</code>
<code>if test -z "$git_remote_testgit_push_error"</code>
<code>echo "ok $ref"</code>
<code>else</code>
<code>echo "error $ref $git_remote_testgit_push_error"</code>
第一行的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()方法。
<code>case command == "export\n":</code>
<code>beforerefs, err := gitlistrefs()</code>
<code>return fmt.errorf("command export: collecting before refs: %v", err)</code>
<code>cmd := exec.command("git", "fast-import", "--quiet",</code>
<code>"--import-marks="+gomarks,</code>
<code>"--export-marks="+gomarks)</code>
<code>cmd.stdin = os.stdin</code>
<code>return fmt.errorf("command export: git fast-import: %v", err)</code>
<code>afterrefs, err := gitlistrefs()</code>
<code>return fmt.errorf("command export: collecting after refs: %v", err)</code>
<code>for refname, objectname := range afterrefs {</code>
<code>if beforerefs[refname] != objectname {</code>
<code>fmt.printf("ok %s\n", refname)</code>
執行 go install,應該能夠建構和安裝 git-remote-go 到 go/bin。
你可以這樣來測試驗證:首先建立兩個空的git倉庫,然後在testlocal中commit一個送出,并通過我們新的輔助插件helper把它push到testremote。
<code>$ cd $home</code>
<code>$ git init testremote</code>
<code>initialized empty git repository in $home/testremote/.git/</code>
<code>$ git init testlocal</code>
<code>initialized empty git repository in $home/testlocal/.git/</code>
<code>$ cd testlocal</code>
<code>$ echo 'hello, world!' >hello.txt</code>
<code>$ git add hello.txt</code>
<code>$ git commit -m "first commit."</code>
<code>[master (root-commit) 50d3a83] first commit.</code>
<code>1 file changed, 1 insertion(+)</code>
<code>create mode 100644 hello.txt</code>
<code>$ git remote add origin go::$home/testremote</code>
<code>to go::$home/testremote</code>
<code>* [new branch] master -> master</code>
<code>$ cd ../testremote</code>
<code>$ git checkout master</code>
<code>$ ls</code>
<code>hello.txt</code>
<code>$ cat hello.txt</code>
<code>hello, world!</code>
<code>$ git remote add usb grave::/media/usb/backup.grave</code>
<code>$ git push --all backup</code>
本文來自雲栖社群合作夥伴“linux中國”,原文釋出日期:2015-08-21