2021-05-12 14:32:11
如何編寫一個全新的 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
很遺憾的是,我發現文件在真正實現一個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){
returnnil
}elseif 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 =="capabilitiesn":
// ...
case command =="n":
returnnil
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 =="capabilitiesn":
fmt.Printf("importn")
fmt.Printf("exportn")
fmt.Printf("refspec %sn", refspec)
fmt.Printf("*import-marks %sn", gitmarks)
fmt.Printf("*export-marks %sn", 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{
returnnil, 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)
}
returnstring(bytes.TrimSpace(out)),nil
}
然後可以像這樣來實現list命令。
case command =="listn":
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("? %sn", refname)
}
fmt.Printf("@%s HEADn", 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
whiletrue
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"
exit1
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=%sn", gitmarks)
fmt.Printf("feature export-marks=%sn", gitmarks)
fmt.Printf("feature donen")
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("donen")
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
exit1
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 =="exportn":
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 %sn", 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。
GitHub 教學系列文章:
GitHub 使用教學圖文詳解 http://www.linuxidc.com/Linux/2014-09/106230.htm
Git 標籤管理詳解 http://www.linuxidc.com/Linux/2014-09/106231.htm
Git 分支管理詳解 http://www.linuxidc.com/Linux/2014-09/106232.htm
Git 遠端倉庫詳解 http://www.linuxidc.com/Linux/2014-09/106233.htm
Git 本地倉庫(Repository)詳解 http://www.linuxidc.com/Linux/2014-09/106234.htm
Git 伺服器搭建與用戶端安裝 http://www.linuxidc.com/Linux/2014-05/101830.htm
Git 概述 http://www.linuxidc.com/Linux/2014-05/101829.htm
分享實用的GitHub 使用教學 http://www.linuxidc.com/Linux/2014-04/100556.htm
Ubuntu下Git伺服器的搭建與使用指南 http://www.linuxidc.com/Linux/2015-07/120617.htm
相關文章