500行JS程式碼打造你的專屬GIT
程式碼來自開源,也迴流開源,有需要且不嫌棄的可以上去看看ofollow,noindex" target="_blank">https://github.com/notechsolution/gitdou
緣起
跟GIT的結緣開始於2011年,公司決定不用原來的IBM Clearcase,改用開源的GIT。作為當時GIT的內部support,確實有很長一段時間跟它廝混在一起。後來還寫了幾篇如何使用GIT的文章,有空可以翻翻GIT七年之癢 . 前兩年回一下爐,又寫了幾篇GIT入門 .
最近看到一個叫Richard Feynman 的人說過這麼一句話
What I cannot create, I do not understand - Richard Feynman
嗯嗯,有點意思,扒拉了一下,還有不少人用Javascript寫GIT。這次的實現主要也是參考了其中一個叫gitlet 的
用什麼錘子?| 技術棧
GIT是Linux Torvalds用C語言寫的。小的不才不懂C,那就用Javascript寫寫吧, ES6 可以讓程式碼可以寫得比較簡潔。既然重造輪子,那就儘量少用框架吧。但是作為lodash粉,還是忍不住了,最後還是用了lodash~~~.
當然,Pivotal Lab中毒較深,做個練習也離不開TDD,所以這次也用了Ava作為testing框架。 但功力尚淺,有些case也偷懶了,testcase跟程式碼的函式比例只做到1:1, 500行的程式碼只有500行的unittest。
錘出個什麼東東?| 實現哪些功能
這次的目的是為了加深對GIT底層實現原理的理解,而不是做出一個真正的產品出來,所以對於使用者操作沒有做出各種友好的提醒,比如沒有像Already up to date
這樣的提醒等等,只要實現了GIT的如下核心命令:
- init
- add
- rm
- commit
- checkout
- branch
- remote
- fetch
- merge
- pull
- push
咋錘的?| 實現過程
下面嘗試逐一來解釋一下每個命令是幹什麼的。
gitdou.init
首先是初始化一個GIT的專案。GIT在某種程度上可以理解為一個檔案的資料庫,裡面儲存著所有檔案的所有版本。初始化的過程也就是建立各個檔案以及目錄.
.gitdou ├── HEAD ├── config ├── objects └── refs ├── heads
ref: refs/heads/master
初始化的過程就是在指定的目錄.gitdou
下生成這些目錄及檔案的過程。程式碼就比較簡單,根據目錄結構,生成對應的檔案樹:
init: () => { const gitdouStructure = { HEAD: 'ref: refs/heads/master', objects: {}, refs: { heads: {} }, config: JSON.stringify({core: {bare: false}}, null, 2) } files.writeFilesFromTree({'.gitdou': gitdouStructure}, process.cwd()); },
add
前面說到了git實際是一個數據庫,存放了所有檔案的所有歷史版本。為了更方便高效地查詢,資料庫都會建立索引。git也不例外,它也有一個index檔案,記錄所有檔案的路徑,這些檔案的狀態以及當前版本的hash值。
add
命令就是將指定路徑的所有檔案的路徑,狀態以及當前的hash值記錄儲存到index檔案裡面。其實現過程就是掃出指定目錄下的所有檔案,逐一計算他們的hash值,然後寫到index檔案裡面
add: path => { const addedFiles = files.listAllMatchedFiles(path); index.updateFilesIntoIndex(addedFiles, {add: true}); }
rm
有新增命令,對應的也就應該有刪除命令。其過程跟add基本一致,只不過多了一步把要刪除的檔案從當前workingCopy裡面刪除掉。
rm: path => { const deletedFiles = files.listAllMatchedFiles(path); index.updateFilesIntoIndex(deletedFiles, {remove: true}); files.removeFiles(deletedFiles); }
commit
當任務已經到一段落,我們需要給當前版本做一個快照,方便以後找回。這時我們可以做一個commit。這個commit將會包含一個hash樹,這棵樹將當前版本的所有檔案連起來。當然還包含了一些commit的metadata,比如誰,什麼時候commit,commit的備註是什麼等等。
具體實現大致為:
- 建立一個hash樹,將所有檔案連起來,並且儲存到objects資料庫裡面
- 建立一個commit物件,包含hash樹的hash,commit的訊息,commit的時間,如果有父親hash,也包含進來。同樣將這個commit的物件儲存到objects資料庫裡面
- 更新當前branch,指向新的commit hash
commit: option => { // write current index into tree object const treeHash = gitdou.write_tree(); // create commit object based on the tree hash const parentHash = refs.getParentHash(); const commitHash = objects.createCommit({treeHash, parentHash, option}); // point the HEAD to commit hash refs.updateRef({updateToRef: 'HEAD', hash: commitHash}) }
branch
GIT的分支管理是可能稍微複雜一些,不同公司,不同的開發模式會有不同的分支管理,甚至有人將這個上升到分支管理的藝術的高度。最有名的分支管理模型應該就是A successful Git branching model
但... 但... branch在GIT的實現裡面可以說是最最簡單的一個了,所謂建立branch就是在.gitdou\refs\heads
建立一個用branch名字命名的檔案,檔案的內容就是當前的hash. 突然想起某學習機廣告:SO EASY~~~
branch : (name, opts) => { const hash = refs.hash('HEAD'); refs.updateRef({updateToRef:name, hash}); },
checkout
不能都是那麼容易的啦!要不也不用花這麼多時間寫!checkout就稍複雜一些。checkout有點類似於還原現場
. 將當前workingCopy還原成指定commit或者branch對應的工作環境。
前面commit命令的時候說到:建立一個hash樹,將所有檔案連起來,並且儲存到objects資料庫裡面
。所有首先我們要找出指定commit或者branch的hash樹。再找出當前程式碼庫版本的hash樹。然後站在當前程式碼庫hash樹的角度,比較這出哪裡改了,哪裡刪了,哪裡新增的。最後將這些不同落實到當前程式碼庫中。當然,別忘了更新HEAD檔案指向checkout的commit或者branch
checkout: (ref) => { const targetCommitHash = refs.hash(ref); const diffs = diff.diff(refs.hash('HEAD'), targetCommitHash); workingCopy.write(diffs); refs.write('HEAD',`ref: ${refs.resolveRef(ref)}`); }
remote
上面的這些命令基本都是在本地自己玩而已,後面這幾個命令就涉及到跟其他人協作了!不過為了簡單,協作也是通過檔案系統操作而已,沒有經過http,但是原理基本一樣!
remote命令只要是用來管理有遠端程式碼庫的配置資訊,GIT裡面remote命令實現了很多子命令,比如有remote ls
,remote show
,remote add
,remote remove
。我們這裡只實現剛需的add
命令
remote add
命令將會讀出程式碼庫的配置檔案.gitdou\config
,然後在裡面新增remote的屬性
remote : (command, name, path) => { const cfg = config.read(); cfg['remote'] = cfg['remote'] || {}; cfg['remote'][name] = path; config.write(cfg); },
新增後的.gitdou\config
檔案內容大致如下 (這裡採用的是JSON格式存取)
{ "core": { "bare": false }, "remote": { "origin": "git@github" } }
fetch
remote已經準備好了,接著我們可以拉取其他人的程式碼庫了!在真正GIT的實現中,這時就涉及到跟GIT伺服器互動的細節,不過我們這裡都是在本地,所有情況比較簡單。
首先我們要在remote的工作目錄下面,讀取他objects資料庫的所有物件,然後將這些物件寫到我們的objects資料庫裡面,再將最新的hash更新到refs/remotes/origin/${branch}
fetch : (remote, branch) => { const remoteUrl = config.read()['remote'][remote]; const remoteHash = refs.getRemoteHash(remoteUrl, branch); const remoteObjects = refs.getRemoteObjects(remoteUrl); _.each(remoteObjects, content => objects.write(content)); refs.updateRef({updateToRef:refs.getRemoteRef(remote, branch), hash:remoteHash}); refs.write("FETCH_HEAD", `${remoteHash} branch '${branch}' of ${remoteUrl}`); return ["From " + remoteUrl, "Count " + remoteObjects.length, branch + " -> " + remote + "/" + branch].join("\n") + "\n"; }
merge
fetch
的確是拿到了對方的所有物件,但是本地的程式碼絲毫沒有變化,因為還沒有將這些合併到我們的程式碼庫裡面。merge做的就是這事。
這個版本我們只實現了沒有衝突的場景,也就是可以fastforward的情況。
首先我們拿到remote的hash樹,再讀取我們當前的hash樹,然後判斷是否可以fastforward (也就是判斷remote是否包含了我們最新的程式碼),然後跟checkout類似,站在當前程式碼庫的角度,找出兩顆hash樹的異同點,將這些異同點寫到當前程式碼庫。最後更新當前程式碼庫的當前branch,指向最新的commit
merge: (ref) => { const receiverHash = refs.hash('HEAD'); const giverHash = refs.hash(ref); if(merger.canFastForward({receiverHash, giverHash})){ merger.writeFastForwardMerge({receiverHash, giverHash}); return 'Fast-forward'; } return 'Non Fast Foward, not handle now'; }
pull
有了fetch跟remote命令,pull就躺著數錢了!因為pull(remote, branch) = fetch(remote, branch) + merge('FETCH_HEAD')
pull: function(remote, branch) { gitdou.fetch(remote, branch); return gitdou.merge("FETCH_HEAD"); }
push
來而不往非禮也!有pull也得有push。push的實現原理有點粗暴!直接跳轉到對方的工作目錄下,然後把自己的objects裡面的所有物件寫到對方的程式碼庫裡面,再幫對方更新對方的branch引用! 細思極恐,好在真正的GIT不是這樣處理的!
push: ref => { const onRemote = util.onRemote(remoteUrl); const remoteUrl = config.read()['remote'][ref]; const receiverHash = onRemote(refs.hash, ref); const giverHash = refs.hash('HEAD'); objects.allObjects().forEach(item => onRemote(objects.write, item)); onRemote(gitdou.updateRef, refs.resolveRef(ref), giverHash); }
結語
從有用的角度看,這次GITDOU的實現並無卵用!
從無用的角度看,這次GITDOU的實現還挺有用!