敏捷軟體開發宣言之實踐與應用 操作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物件有以下四種:

  1. blob 物件:就是工作目錄中某個檔案的 "內容",且只有內容而已,當你執行 git add 指令的同時,這些新增檔案的內容就會立刻被寫入成為 blob 物件,檔名則是物件內容的雜湊運算結果,沒有任何其他其他資訊,像是檔案時間、原本的檔名或檔案的其他資訊,都會儲存在其他類型的物件裡 (也就是 tree 物件)。
  2. tree 物件:這類物件會儲存特定目錄下的所有資訊,包含該目錄下的檔名、對應的 blob 物件名稱、檔案連結(symbolic link) 或其他 tree 物件等等。由於 tree 物件可以包含其他 tree 物件,所以瀏覽 tree 物件的方式其實就跟檔案系統中的「資料夾」沒兩樣。簡單來說,tree 物件這就是在特定版本下某個資料夾的快照(Snapshot)。
  3. commit 物件:用來記錄有那些 tree 物件包含在版本中,一個 commit 物件代表著 Git 的一次提交,記錄著特定提交版本有哪些 tree 物件、以及版本提交的時間、紀錄訊息等等,通常還會記錄上一層的 commit 物件名稱 (只有第一次 commit 的版本沒有上層 commit 物件名稱。
  4. 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的資料夾。

9003ea09

資料夾裡面應該要有一個檔案由SHA1雜湊後得到名稱的檔案。

b3fe75cc

讓我們繼續保存先前的更改,包括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目前已經切換的分支,我們會比對masterHEAD以及比對前面製作的commit所得到的commit是否一樣:

head = repo.refs[b'HEAD']
head == commit.id
True
head == repo.refs[b'refs/heads/master']
True

那Git是怎麼工作的?Git中有兩種參照,一種是「絕對名稱」,另一個是「參照名稱」(ref),例如前面的masterHEAD就是一種「參照名稱」(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.idgit 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)))

參考資料

留言

這個網誌中的熱門文章

在手機不用任何下載影片的工具或網頁下載影片

自己做免安裝程式