敏捷軟體開發宣言之實踐與應用 操作Git的Python套件:subprocess、Dulwich、pygit2、GitPython、difflib
內容
概念
計算每次的合併請求的字數計算,此方法使用較為笨呆的方式,使用Git clone此儲存庫,並利用object的資料夾裡面搜尋此次的變更並比對,來達到比對的作用,而下載會使用SSH金鑰或Token來作為檔案放置。
不過後來發現可以直接在CI運作的時候就可以執行Git指令與相關程式,並且將沒有Pull的內容Pull下來,但這次以改以Fetch而且也找到了可以執行Git的Python套件,因此會利用此來做比對並將字數計算器結合來使用。以下是發現的相關套件:
使用方法
Dulwich
儲存庫
匯入Repo
類別,此類別用於操作「儲存庫」(Repository),詳細文件說明可以參考class dulwich.repo.Repo(root)。
from dulwich.repo import Repo
「儲存庫」(Repository)分成兩種:
- 儲存庫:含有「工作目錄」(working directory)與
.git
,可使用git init RepositoryName
來建立。 - 裸儲存庫:不含有「工作目錄」(working directory)與
.git
,通常提供遠端與備份使用,可使用git init RepositoryName --bare
來建立。
建立儲存庫
建立一個資料夾並建立儲存庫,類似於git init
。先匯入套件系統相關的套件,如果還沒有匯入Repo
也要記得到儲存庫使用from dulwich.repo import Repo
匯入。
from os import mkdir import sys
接下來建立儲存庫的資料夾,與實際操作Git時一樣要先建立資料夾才能初始化儲存庫。
mkdir("myrepo")
接下來初始化儲存庫,呼叫變數repo
來檢查是否建立成功。
repo = Repo.init("myrepo") repo
如果出現下列訊息則代表建立成功。
<Repo at 'myrepo'>
您已經可以查看myrepo/.git
目錄狀況,儘管目前此目錄它幾乎為空。
打開儲存庫
要重新打開現有存儲庫,只需將其路徑提供給類別Repo
即可:
repo = Repo("myrepo") repo
如果出現下列訊息則代表建立成功。
<Repo at 'myrepo'>
建立索引
建立「索引」(Index)以提供「暫存區」(Staging area)的功能。「工作目錄」的變更寫入到「索引檔」(Index file)後,「索引」(Index)中追蹤的檔案將記錄為準備提交,詳細的索引介紹可以參閱第 07 天:解析 Git 資料結構 - 索引結構。如上述,只有含有「工作目錄」(working directory)的「存儲庫」(Repository)具有「索引」(Index)。要打開索引檔」(Index file),只需呼叫:
index = repo.open_index() print(index.path) list(index)
由於存儲庫是剛剛創建的,因此「索引檔」(Index file)將為空。
暫存新檔案
該「存儲庫」(Repository)允許「暫存」(Staging)檔案。Git只會追蹤檔案,目錄並不會被Git所追蹤。讓我們建立一個簡單的文字文件並將它「暫存」(Staging):
f = open('myrepo/foo', 'wb') _ = f.write(b"monty") f.close() repo.stage([b"foo"])
現在將「索引」(Index)中的紀錄顯示出來,我們讀取「索引檔」(Index file):
for f in repo.open_index(): print(",".join([f.decode(sys.getfilesystemencoding()) ]))
提交一個版本
現在我們已經有了一個變更的檔案在「暫存」(State)裡面,我們可以「提交」(commit)變更的檔案。最簡單的方法是直接呼叫Repo.do_commit
函式。當然除了使用高階用法,也可以使用較為低階且麻煩的使用方式,可以依照個人的需求呼叫使用,可以參閱The object store。
要在當前「分支」(branch)上創建簡單的「提交」(commit),只需指定消息。如果未指定提交者和作者,則將從存儲庫配置或全局配置中檢索它們:
commit_id = repo.do_commit( b"The first commit", committer=b"Jelmer Vernooij <jelmer@samba.org>")
do_commit
返回「提交」(commit)的SHA1。由於「提交」是預設「分支」(branch)master
,因此現在「存儲庫」的HEAD
會被設定指向「提交」的SHA1,因此使用do_commit
所回傳的SHA1給commit_id
去比對read_po.head()
會出現True
:
repo.head() == commit_id
物件型儲存
以下是「物件型儲存」(Object Store)在IBM官網上的解釋:
「物件型儲存」(Object Store)是一種常在雲端中使用的無階層資料儲存方法。不同於其他的資料儲存方法,物件型儲存不使用目錄樹。離散的資料單元(物件)存在於儲存區中的相同層次。每個物件都有唯一的識別性名稱,以供應用程式用來擷取物件。此外,每個物件可能具有隨同物件本身一起擷取的 meta 資料。
主要特性
- 資料儲存為離散物件。
- 資料不放在目錄的階層中,而是存在於平坦的位址空間。
- 應用程式按唯一位址識別離散的資料物件。
- 常用代客泊車來做比較,資料物件就像一輛汽車;位址則是收據。
- 專門設計用來在應用程式層級使用 API 來存取資料,而不是在使用者層級。
在這個定義下與Git的運作原理非常相似,因此開發者在設計此套件、模組、類別與函式時會以「物件」(Object)的相關運作與名詞來命名,而前面實作的儲存庫所使用的方式就是建立在這些套件、模組、類別與函式上面,除了避免只為單一功能設計,也提供未來靈活的使用這項套件。
首先一樣先建立並初始化一個儲存庫,先匯入套件,然後指定「儲存庫」(Repository)目錄。
from dulwich.repo import Repo repo = Repo.init("myrepo", mkdir=True)
初始提交
這邊值得注意的是,此方式是以Git中物件的觀念進行操作,也就是需要了解Git的「物件結構」,可以參閱第 06 天:解析 Git 資料結構 - 物件結構來了解詳細的內容,基本上Git物件有以下四種:
- blob 物件:就是工作目錄中某個檔案的 "內容",且只有內容而已,當你執行 git add 指令的同時,這些新增檔案的內容就會立刻被寫入成為 blob 物件,檔名則是物件內容的雜湊運算結果,沒有任何其他其他資訊,像是檔案時間、原本的檔名或檔案的其他資訊,都會儲存在其他類型的物件裡 (也就是 tree 物件)。
- tree 物件:這類物件會儲存特定目錄下的所有資訊,包含該目錄下的檔名、對應的 blob 物件名稱、檔案連結(symbolic link) 或其他 tree 物件等等。由於 tree 物件可以包含其他 tree 物件,所以瀏覽 tree 物件的方式其實就跟檔案系統中的「資料夾」沒兩樣。簡單來說,tree 物件這就是在特定版本下某個資料夾的快照(Snapshot)。
- commit 物件:用來記錄有那些 tree 物件包含在版本中,一個 commit 物件代表著 Git 的一次提交,記錄著特定提交版本有哪些 tree 物件、以及版本提交的時間、紀錄訊息等等,通常還會記錄上一層的 commit 物件名稱 (只有第一次 commit 的版本沒有上層 commit 物件名稱。
- tag 物件:是一個容器,通常用來關聯特定一個 commit 物件 (也可以關聯到特定 blob、tree 物件),並額外儲存一些額外的參考資訊(metadata),例如: tag 名稱。使用 tag 物件最常見的情況是替特定一個版本的 commit 物件標示一個易懂的名稱,可能是代表某個特定發行的版本,或是擁有某個特殊意義的版本。
其整個物件結構圖如下:
因此在這裡作者使用的方式是直接將變化寫在「儲存庫」(Repository)裡面,類似於直接操作「裸儲存庫」,不過這個過程都是在Python執行後在最後一個步驟才會儲存在Git儲存庫,因此這個過程中會有一段變更的內容不會出現在.git/
裡面。當然,您可以from_file
改用現有文件中的Blob 。通常在使用Git時添加或修改檔案。由於「存儲庫」暫時為空,因此要先在「儲存庫」(Repository)裡面添加一個新檔案:
from dulwich.objects import Blob blob = Blob.from_string(b"My file content\n") print(blob.id.decode('ascii'))
接下來因為在Git裡面檔案的資訊會與檔案內容分開,因此還會有一個Tree物件紀錄檔案資訊,因此我們要透過Tree物件幫檔案加上資訊。讓我們為檔案加上一個檔名:
from dulwich.objects import Tree tree = Tree() tree.add(b"spam", 0o100644, blob.id)
請注意,0o100644
是在Linux中透過八進位方式設定此檔案權限。您可以對它們進行更進階的權限操作,可以使用os
套件的stat
模組。
此時「存儲庫」的Tree物件會需要有有一個對應的時間戳。這就是透過object
從底層來操作「提交」(commit)的工作:
from dulwich.objects import Commit, parse_timezone from time import time commit = Commit() commit.tree = tree.id author = b"Your Name <your.email@example.com>" commit.author = commit.committer = author commit.commit_time = commit.author_time = int(time()) tz = parse_timezone(b'-0200')[0] commit.commit_timezone = commit.author_timezone = tz commit.encoding = b"UTF-8" commit.message = b"Initial commit"
請注意,在初始提交不會有上一個版本的分支。此時,「存儲庫」仍為空,因為所有操作都在Python的函式中進行。讓我們真正的「提交」它,把他儲存在「儲存庫」裡面。我們先儲存前面創建的Blob物件。
object_store = repo.object_store object_store.add_object(blob)
此時裡面應該要有在.git/objects
例面應該要有一個c5
的資料夾。
資料夾裡面應該要有一個檔案由SHA1雜湊後得到名稱的檔案。
讓我們繼續保存先前的更改,包括Tree物件與commit物件:
object_store.add_object(tree) object_store.add_object(commit)
現在,「存儲庫」包含三個物件:Blob、Tree與Commit,但仍然沒有「分支」(Branch)。讓我們像Git那樣創建master
分支,創見的方式是到.git/refs/heads/master
指向剛剛的commit物件,當執行repo.refs
的時候會建立分支以及將指定「Commit物件」,但不會變更目前的分支位置:
repo.refs[b'refs/heads/master'] = commit.id
現在,使用master
分支的時候Git可以知道要從何處開始提交。當我們commit新的版本在master
分支的時候,我們也在移動HEAD
,這是Git目前已經切換的分支,我們會比對master
、HEAD
以及比對前面製作的commit所得到的commit是否一樣:
head = repo.refs[b'HEAD'] head == commit.id True head == repo.refs[b'refs/heads/master'] True
那Git是怎麼工作的?Git中有兩種參照,一種是「絕對名稱」,另一個是「參照名稱」(ref),例如前面的master
與HEAD
就是一種「參照名稱」(ref)。事實證明,HEAD
也是一種特殊的「參照名稱」(ref),稱為「參照名稱」(ref),在目前這個「儲存庫」裡面HEAD
指向master
。因此在這個套件中大多數的函式都是使「參照名稱」(ref)透明的運作,現在可以針對在HEAD
內部進行分析:
import sys print(repo.refs.read_ref(b'HEAD').decode(sys.getfilesystemencoding()))
回傳的內容應該會如下,如果你的分支在master
ref: refs/heads/master
通常,您不需要使用read_ref
來做查詢。如果要更改「參照名稱」(ref)中HEAD
指向的內容,以查詢另一個分支,只需使用set_symbolic_ref
。
現在「儲存庫」正在追蹤名為master
的分支,該分支的「紀錄」(log)只有一次「提交」(commit)。
再次玩一次Git
此時,您可以退出Python回到shell,進入myrepo
目錄裡面,然後輸入git branch
確認分支是否存在。接著我們在使用git status
來確認與比對「工作目錄」、「索引」與「儲存庫」之間的狀況,其結果應該要如下:
~/myrepo$ git status On branch master Changes to be committed: (use "git reset HEAD <file>..." to unstage) deleted: spam
Git會告訴您spam
檔案已經刪除,這是正常的現象,因為Git比對「工作目錄」、「索引」與「儲存庫」之間的狀況,發現「工作目錄」沒有spam
這個檔案,原因是因為我們直接對「儲存庫」進行操作。而且我們絕對沒有使用Dulwich對「工作目錄」進行操作,因為我們根本不需要這樣!
您可以使用「切換」(checkout)取得最後一個狀況。使用git checkout -f
強制取得「儲存庫」中spam
的檔案,參數-f
會強制將「工作目錄」中尚未加入「索引」的更改還原。
接下來檔案spam
會出現,毫不奇怪地檔案的內容包含與Blob物件相同的內容:
$ cat spam My file content
修改檔案後提交
現在我們有了第一個提交,下一個提交將有所不同。正如在介紹中所見,這次實作的內容是將Tree物件指向新Blob物件。舊的Blob物件將保留以計算差異。同時Tree物件也會被修改,而新的版本將指向新的commit物件。
讓我們首先建立新的Blob物件:
from dulwich.objects import Blob spam = Blob.from_string(b"My new file content\n") print(spam.id.decode('ascii'))
另一種方式是更改以前的Blob物件,讓他與工作目錄不同:
blob.data = b"My new file content\n" print(blob.id.decode('ascii'))
無論如何,請更新稱為spam
的Blob物件。您還可以更改其模式:
tree[b"spam"] = (0o100644, spam.id)
現在,我們記錄下更改:
from dulwich.objects import Commit from time import time c2 = Commit() c2.tree = tree.id c2.parents = [commit.id] c2.author = c2.committer = b"John Doe <john@example.com>" c2.commit_time = c2.author_time = int(time()) c2.commit_timezone = c2.author_timezone = 0 c2.encoding = b"UTF-8" c2.message = b'Changing "spam"'
在這次的提交中,commit物件將記錄含有修改內容的Tree物件的「絕對名稱」,除此之外最重要的是,此次提交將記錄上一個版本的「絕對名稱」。上一個版本實際上會是一個串列輸入,因為合併分支時會有可能同時擁有多個分支合併。
讓我們將上面製作的三種物件放入「儲存庫」中:
repo.object_store.add_object(spam) repo.object_store.add_object(tree) repo.object_store.add_object(c2)
現在您已經可以透過git show
來查看剛剛提交的檔案,此時你可以看到spam
檔案在上一個版本與目前這個版本之間的差異。並且還可以看到c2.id
與git show
一樣。
timmy@timmy-desktop:~/Git/myrepo$ git show commit a840b3cec9dde0718223a1f1bf4506b029829862 Author: Your Name <your.email@example.com> Date: Thu Apr 23 00:35:30 2020 -0200 Initial commit diff --git a/spam b/spam new file mode 100644 index 0000000..c55063a --- /dev/null +++ b/spam @@ -0,0 +1 @@ +My file content
除了使用git show
外,可以使用write_tree_diff
列印出上一個版本與目前版本之間的差異:
from dulwich.patch import write_tree_diff from io import BytesIO out = BytesIO() write_tree_diff(out, repo.object_store, commit.tree, tree.id) import sys _ = sys.stdout.write(out.getvalue().decode('ascii'))
您不會使用git log
看到它,因為HEAD
仍然指向前一個提交的版本。這個很容易補救:
repo.refs[b'refs/heads/master'] = c2.id
現在,所有操作與工作方式都是按照Git設計。
比對
是一個使用Python所撰寫的Git操作工具,使用的方式是利用操作Git的物件結構來達到相同操作的結果,因此必須要了解Git的物件結構,請參閱第 06 天:解析 Git 資料結構 - 物件結構。
from dulwich.repo import Repo r = Repo('.') r.head() c = r[r.head()] c.message c.message.decode('utf8') r.head()
使用Dulwich比較指定的commit並自動尋找前一個commit進行比較,此範例以指定的commit作為基礎,自動使用前一個commit當作比較,然後呈現比較的內容變化,此方式如同Git的git diff HEAD^ HEAD
。
from dulwich.repo import Repo from dulwich.patch import write_tree_diff import sys repo_path = "." commit_id = b"c30d8b35af82e0cfb5b471621d5e7c95d7789646" r = Repo(repo_path) commit = r[commit_id] parent_commit = r[commit.parents[0]] outstream = getattr(sys.stdout, 'buffer', sys.stdout) write_tree_diff(outstream, r.object_store, parent_commit.tree, commit.tree)
執行結果:
diff --git a/requirements.txt b/requirements.txt
index d32d86b..537c8f7 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -3,3 +3,5 @@
flake8
pytest
pylint
+GitPython
+dulwich
>>> parent_commit
<Commit b'a47a2150f7cce3ba3164a942aea6165a111084f6'>
>>> outstream
<_io.BufferedWriter name='<stdout>'>
如果要拿來比較目前分支與master之間的狀況,可以用以下方法。
from dulwich.repo import Repo from dulwich.patch import write_tree_diff import sys repo_path = "." commit_id1 = b"5cdfab9e0df5fdb978954a697a8f3f2ab6aeb79f" commit_id2 = b"c30d8b35af82e0cfb5b471621d5e7c95d7789646" r = Repo(repo_path) commit = r[commit_id1] parent_commit = r[commit_id2] outstream = getattr(sys.stdout, 'buffer', sys.stdout) write_tree_diff(outstream, r.object_store, parent_commit.tree, commit.tree)
執行的結果。
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 5138408..4c83f1e 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -23,11 +23,6 @@ - pip install -r requirements.txt -U - virtualenv venv - source venv/bin/activate - - pwd - - git log -5 - - git branch --all - - git fetch origin master:master - - git branch --all flake8: script: diff --git a/requirements.txt b/requirements.txt index 537c8f7..d32d86b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,5 +3,3 @@ flake8 pytest pylint -GitPython -dulwich
如果要指定某一個檔案修改的內容,可以用以下方法。
from dulwich.object_store import tree_lookup_path from dulwich.repo import Repo r = Repo('.') mode1, sha1 = tree_lookup_path(r.__getitem__, r[c1].tree, path) mode2, sha2 = tree_lookup_path(r.__getitem__, r[c2].tree, path) import difflib difflib.unified_diff(r[sha1].data.splitlines(), r[sha2].data.splitlines())
如果要指定某一個分支或者「參照名稱」去比對某一個參照名稱,可以使用以下方法,以下方法會用在這次計算字數當中。
import sys from io import BytesIO from dulwich.patch import write_tree_diff from dulwich.repo import Repo REPO_PATH = "." OUT = BytesIO() REPO = Repo(REPO_PATH) HEAD = REPO.refs[b'HEAD'] MASTER = REPO.refs[b'refs/remotes/origin/master'] HEAD_COMMIT = REPO[HEAD] MASTER_COMMIT = REPO[MASTER] write_tree_diff(OUT, REPO.object_store, HEAD_COMMIT.tree, MASTER_COMMIT.tree) _ = sys.stdout.write(OUT.getvalue().decode('utf8'))
遠端儲存庫
遠端儲存庫的相關操作。
標籤
標籤。
Porcelain
有如使用Git指令一樣的操作Python套件。
總結
此教學只含蓋Dulwich的一小部分但卻是很重要的,因為裡面解釋與示範如何透過控制物件來達到操作Git。Dulwich仍然需要擴展到包、參照、「歷史紀錄」(reflog)和網路通訊。
Dulwich可以使用Git大部分的功能,因此還有更多值得看一看的地方。
所有人,就是現在!
pygit2
目前還在製作,歡迎共筆。
GitPython
目前還在編輯,歡迎共筆。
difflib
""" -*- coding: utf-8 -*- @Time : 2017/10/1 16:22 @File : Difflib.py @Software: PyCharm """ import difflib text1 = """ This is Text1 人生苦短,我用Python! Python!Python!Python! """ text2 = """ This is Text2 人生苦短,我用Python! Python!Python!Python! """ text1_lines = text1.splitlines() # 分別以行進行分割 text2_lines = text2.splitlines() d = difflib.Differ() # 創建Differ物件 diff = d.compare(text1_lines, text2_lines) print('\n'.join(list(diff)))
參考資料
- GitPython 3.1.1
- GitPython
- Dulwich
- pygit2
- GitPython Documentation
- Obtaining Diff Information
- Diff
- 如何使用 Python 操作 Git 代碼?GitPython 入門介紹
- [python]dulwich 只是個 git client 我想…
- 第 06 天:解析 Git 資料結構 - 物件結構
- 第 07 天:解析 Git 資料結構 - 索引結構
- 第 10 天:認識 Git 物件的絕對名稱
- 第 11 天:認識 Git 物件的一般參照與符號參照
- 第 12 天:認識 Git 物件的相對名稱
- How to reproduce 'git diff commitA commitB -- file' with Dulwich
- diff.py
- difflib
- Python自动化运维笔记(四):使用difflib模块实现文件内容差异对比
- io — Core tools for working with streams
- subprocess
- 何謂物件型儲存?
留言
張貼留言