Git鉤子:自定義你的工作流
BY 童仲毅(geeeeeeeeek@github)
這是一篇在原文(BY atlassian)基礎上演繹的譯文。除非另行注明,頁面上所有內容采用知識共享-署名(CC BY 2.5 AU)協議共享。
Git鉤子是在Git倉庫中特定事件發生時自動運行的腳本。它可以讓你自定義Git內部的行為,在開發周期中的關鍵點觸發自定義的行為。
Git鉤子最常見的使用場景包括推行提交規范,根據倉庫狀態改變項目環境,和接入持續集成工作流。但是,因為腳本可以完全定制,你可以用Git鉤子來自動化或者優化你開發工作流中任意部分。
在這篇文章中,我們會先簡要介紹Git鉤子是如何工作的。然后,我們會審視一些本地和遠端倉庫使用最流行的鉤子。
概述
Git鉤子是倉庫中特定事件發生時Git自動運行的普通腳本。因此,Git鉤子安裝和配置也非常容易。
鉤子在本地或服務端倉庫都可以部署,且只會在倉庫中事件發生時被執行。在文章后面我們會具體地研究各種鉤子。接下來所講的配置對本地和服務端鉤子都起作用。
安裝鉤子
鉤子存在于每個Git倉庫的.git/hooks目錄中。當你初始化倉庫時,Git自動生成這個目錄和一些示例腳本。當你觀察.git/hooks時,你會看到下面這些文件:
applypatch-msg.sample pre-push.sample commit-msg.sample pre-rebase.sample post-update.sample prepare-commit-msg.sample pre-applypatch.sample update.sample pre-commit.sample
這里已經包含了大部分可用的鉤子了,但是.sample拓展名防止它們默認被執行。為了安裝一個鉤子,你只需要去掉.sample拓展名。或者你要寫一個新的腳本,你只需添加一個文件名和上述匹配的新文件,去掉.sample拓展名。
比如說,試試安裝一個prepare-commit-msg鉤子。去掉腳本的.sample拓展名,在文件中加上下面這兩行:
#!/bin/sh echo "# Please include a useful commit message!" > $1
鉤子需要能被執行,所以如果你創建了一個新的腳本文件,你需要修改它的文件權限。比如說,為了確保prepare-commit-msg可執行,運行下面這個命令:
chmod +x prepare-commit-msg
接下來你每次運行git commit時,你會看到默認的提交信息都被替換了。我們會在“準備提交信息”一節中細看它是如何工作的。現在我們已經可以定制Git的內部功能,你只需要坐和放寬。
內置的樣例腳本是非常有用的參考資料,因為每個鉤子傳入的參數都有非常詳細的說明(不同鉤子不一樣)。
腳本語言
內置的腳本大多是shell和PERL語言的,但你可以使用任何腳本語言,只要它們最后能編譯到可執行文件。每次腳本中的#!/bin/sh定義了你的文件將被如何解釋。比如,使用其他語言時你只需要將path改為你的解釋器的路徑。
比如說,你可以在prepare-commit-msg中寫一個可執行的Python腳本。下面這個鉤子和上一節的shell腳本做的事完全一樣。
#!/usr/bin/env python import sys, os commit_msg_filepath = sys.argv[1] with open(commit_msg_filepath, 'w') as f: f.write("# Please include a useful commit message!")
注意第一行改成了Python解釋器的路徑。此外,這里用sys.argv[1]而不是$1來獲取第一個參數(這個也后面再講)。
這個特性非常強大,因為你可以用任何你喜歡的語言來編寫Git鉤子。
鉤子的作用域
對于任何Git倉庫來說鉤子都是本地的,而且它不會隨著git clone一起復制到新的倉庫。而且,因為鉤子是本地的,任何能接觸得到倉庫的人都可以修改。
對于開發團隊來說,這有很大的影響。首先,你要確保你們成員之間的鉤子都是最新的。其次,你也不能強行讓其他人用你喜歡的方式提交——你只能鼓勵他們這樣做。
在開發團隊中維護鉤子是比較復雜的,因為.git/hooks目錄不隨你的項目一起拷貝,也不受版本控制影響。一個簡單的解決辦法是把你的鉤子存在項目的實際目錄中(在.git外)。這樣你就可以像其他文件一樣進行版本控制。為了安裝鉤子,你可以在.git/hooks中創建一個符號鏈接,或者簡單地在更新后把它們復制到.git/hooks目錄下。
作為備選方案,Git同樣提供了一個模板目錄機制來更簡單地自動安裝鉤子。每次你使用git init或git clone時,模板目錄文件夾下的所有文件和目錄都會被復制到.git文件夾。
所有的下面講到的本地鉤子都可以被更改或者徹底刪除,只要你是項目的參與者。這完全取決于你的團隊成員想不想用這個鉤子。所以記住,最好把Git鉤子當成一個方便的開發者工具而不是一個嚴格強制的開發規范。
也就是說,用服務端鉤子來拒絕沒有遵守規范的提交是完全可行的。后面我們會再討論這個問題。
本地鉤子
本地鉤子只影響它們所在的倉庫。當你在讀這一節的時候,記住開發者可以修改他們本地的鉤子,所以不要用它們來推行強制的提交規范。不過,它們確實可以讓開發者更易于接受這些規范。
在這一節中,我們會探討6個最有用的本地鉤子:
- pre-commit
- prepare-commit-msg
- commit-msg
- post-commit
- post-checkout
- pre-rebase
前四個鉤子讓你介入完整的提交生命周期,后兩個允許你執行一些額外的操作,分別為git checkout和git rebase的安全檢查。
所有帶pre-的鉤子允許你修改即將發生的操作,而帶post-的鉤子只能用于通知。
我們也會看到處理鉤子的參數和用底層Git命令獲取倉庫信息的實用技巧。
pre-commit
pre-commit腳本在每次你運行git commit命令時,Git向你詢問提交信息或者生產提交對象時被執行。你可以用這個鉤子來檢查即將被提交的代碼快照。比如說,你可以運行一些自動化測試,保證這個提交不會破壞現有的功能。
pre-commit不需要任何參數,以非0狀態退出時將放棄整個提交。讓我們看一個簡化了的(和更詳細的)內置pre-commit鉤子。只要檢測到不一致時腳本就放棄這個提交,就像git diff-index命令定義的那樣(只要詞尾有空白字符、只有空白字符的行、行首一個tab后緊接一個空格就被認為錯誤)。
#!/bin/sh # 檢查這是否是初始提交 if git rev-parse --verify HEAD >/dev/null 2>&1 then echo "pre-commit: About to create a new commit..." against=HEAD else echo "pre-commit: About to create the first commit..." against=4b825dc642cb6eb9a060e54bf8d69288fbee4904 fi # 使用git diff-index來檢查空白字符錯誤 echo "pre-commit: Testing for whitespace errors..." if ! git diff-index --check --cached $against then echo "pre-commit: Aborting commit due to whitespace errors" exit 1 else echo "pre-commit: No whitespace errors :)" exit 0 fi
使用git diff-index時我們要指出和哪次提交進行比較。一般來說是HEAD,但HEAD在創建第一次提交時不存在,所以我們的第一個任務是解決這個極端情形。我們通過git rev-parse --verify來檢查HEAD是否是一個合法的引用。>/dev/null 2>&1這部分屏蔽了git rev-parse任何輸出。HEAD或者一個新的提交對象被儲存在against變量中供git diff-index使用。4b825d...這個哈希字串代表一個空白提交的ID。
git diff-index --cached命令將提交和緩存區比較。通過傳入-check選項,我們要求它在更改引入空白字符錯誤時警告我們。如果它這么做了,我們返回狀態1來放棄這次提交,否則返回狀態0,提交工作流正常進行。
這只是pre-commit的其中一個例子。它恰好使用了已有的Git命令來根據提交帶來的更改進行測試,但你可以在pre-commit中做任何你想做的事,比如執行其它腳本、運行第三方測試集、用Lint檢查代碼風格。
prepare-commit-msg
prepare-commit-msg鉤子在pre-commit鉤子在文本編輯器中生成提交信息之后被調用。這被用來方便地修改自動生成的squash或merge提交。
prepare-commit-msg腳本的參數可以是下列三個:
- 包含提交信息的文件名。你可以在原地更改提交信息。
- 提交類型。可以是信息(-m或-F選項),模板(-t選項),merge(如果是個合并提交)或squash(如果這個提交插入了其他提交)。
- 相關提交的SHA1哈希字串。只有當-c,-C,或--amend選項出現時才需要。
和pre-commit一樣,以非0狀態退出會放棄提交。
我們已經看過一個修改提交信息的簡單例子,現在我們來看一個更有用的腳本。使用issue跟蹤器時,我們通常在單獨的分支上處理issue。如果你在分支名中包含了issue編號,你可以使用prepare-commit-msg鉤子來自動地將它包括在那個分支的每個提交信息中。
#!/usr/bin/env python import sys, os, re from subprocess import check_output # 收集參數 commit_msg_filepath = sys.argv[1] if len(sys.argv) > 2: commit_type = sys.argv[2] else: commit_type = '' if len(sys.argv) > 3: commit_hash = sys.argv[3] else: commit_hash = '' print "prepare-commit-msg: File: %s\nType: %s\nHash: %s" % (commit_msg_filepath, commit_type, commit_hash) # 檢測我們所在的分支 branch = check_output(['git', 'symbolic-ref', '--short', 'HEAD']).strip() print "prepare-commit-msg: On branch '%s'" % branch # 用issue編號生成提交信息 if branch.startswith('issue-'): print "prepare-commit-msg: Oh hey, it's an issue branch." result = re.match('issue-(.*)', branch) issue_number = result.group(1) with open(commit_msg_filepath, 'r+') as f: content = f.read() f.seek(0, 0) f.write("ISSUE-%s %s" % (issue_number, content))
首先,上面的prepare-commit-msg鉤子告訴你如何收集傳入腳本的所有參數。接下來,它調用了git symbolic-ref --short HEAD來獲取對應HEAD的分支名。如果分支名以issue-開頭,它會重寫提交信息文件,在第一行加上issue編號。比如你的分支名issue-224,下面的提交信息將會生成:
ISSUE-224 # Please enter the commit message for your changes. Lines starting # with '#' will be ignored, and an empty message aborts the commit. # On branch issue-224 # Changes to be committed: # modified: test.txt
有一點要記住的是即使用戶用-m傳入提交信息,prepare-commit-msg也會運行。也就是說,上面這個腳本會自動插入ISSUE-[#]字符串,而用戶無法更改。你可以檢查第二個參數是否是提交類型來處理這個情況。
但是,如果沒有-m選項,prepare-commit-msg鉤子允許用戶修改生成后的提交信息。所以腳本的目的是為了方便,而不是推行強制的提交信息規范。如果你要這么做,你需要下一節所講的commit-msg鉤子。
commit-msg
commit-msg鉤子和prepare-commit-msg鉤子很像,但它會在用戶輸入提交信息之后被調用。這適合用來提醒開發者他們的提交信息不符合你團隊的規范。
傳入這個鉤子唯一的參數是包含提交信息的文件名。如果它不喜歡用戶輸入的提交信息,它可以在原地修改這個文件(和prepare-commit-msg一樣),或者它會以非0狀態退出,放棄這個提交。
比如說,下面這個腳本確認用戶沒有刪除prepare-commit-msg腳本自動生成的ISSUE-[#]字符串。
#!/usr/bin/env python import sys, os, re from subprocess import check_output # 收集參數 commit_msg_filepath = sys.argv[1] # 檢測所在的分支 branch = check_output(['git', 'symbolic-ref', '--short', 'HEAD']).strip() print "commit-msg: On branch '%s'" % branch # 檢測提交信息,判斷是否是一個issue提交 if branch.startswith('issue-'): print "commit-msg: Oh hey, it's an issue branch." result = re.match('issue-(.*)', branch) issue_number = result.group(1) required_message = "ISSUE-%s" % issue_number with open(commit_msg_filepath, 'r') as f: content = f.read() if not content.startswith(required_message): print "commit-msg: ERROR! The commit message must start with '%s'" % required_message sys.exit(1)
雖然用戶每次創建提交時,這個腳本都會運行。但你還是應該避免做檢查提交信息之外的事情。如果你需要通知其他服務一個快照已經被提交了,你應該使用post-commit這個鉤子。
post-commit
post-commit鉤子在commit-msg鉤子之后立即被運行 。它無法更改git commit的結果,所以這主要用于通知用途。
這個腳本沒有參數,而且退出狀態不會影響提交。對于大多數post-commit腳本來說,你只是想訪問你剛剛創建的提交。你可以用git rev-parse HEAD來獲得最近一次提交的SHA1哈希字串,或者你可以用git log -l HEAD獲取完整的信息。
比如說,如果你需要每次提交快照時向老板發封郵件(也許對于大多數工作流來說這不是個好的想法),你可以加上下面這個post-commit鉤子。
#!/usr/bin/env python import smtplib from email.mime.text import MIMEText from subprocess import check_output # 獲得新提交的git log --stat輸出 log = check_output(['git', 'log', '-1', '--stat', 'HEAD']) # 創建一個純文本的郵件內容 msg = MIMEText("Look, I'm actually doing some work:\n\n%s" % log) msg['Subject'] = 'Git post-commit hook notification' msg['From'] = 'mary@example.com' msg['To'] = 'boss@example.com' # 發送信息 SMTP_SERVER = 'smtp.example.com' SMTP_PORT = 587 session = smtplib.SMTP(SMTP_SERVER, SMTP_PORT) session.ehlo() session.starttls() session.ehlo() session.login(msg['From'], 'secretPassword') session.sendmail(msg['From'], msg['To'], msg.as_string()) session.quit()
你雖然可以用post-commit來觸發本地的持續集成系統,但大多數時候你想用的是post-receive這個鉤子。它運行在服務端而不是用戶的本地機器,它同樣在任何開發者推送代碼時運行。那里更適合你進行持續集成。
post-checkout
post-checkout鉤子和post-commit鉤子很像,但它在你用git checkout查看引用的時候被調用。這是用來清理你的工作目錄中可能會令人困惑的生成文件。
這個鉤子接受三個參數,它的返回狀態不影響git checkout命令。
- HEAD前一次提交的引用
- 新的HEAD的引用
- 1或0,分別代表是分支checkout還是文件checkout。
Python程序員經常遇到的問題是切換分支后那些之前生成的.pyc文件。解釋器有時使用.pyc而不是.py文件。為了避免歧義,你可以在每次用post-checkout切換到新的分支的時候,刪除所有.pyc文件。
#!/usr/bin/env python import sys, os, re from subprocess import check_output # 收集參數 previous_head = sys.argv[1] new_head = sys.argv[2] is_branch_checkout = sys.argv[3] if is_branch_checkout == "0": print "post-checkout: This is a file checkout. Nothing to do." sys.exit(0) print "post-checkout: Deleting all '.pyc' files in working directory" for root, dirs, files in os.walk('.'): for filename in files: ext = os.path.splitext(filename)[1] if ext == '.pyc': os.unlink(os.path.join(root, filename))
鉤子腳本當前的工作目錄總是位于倉庫的根目錄下,所以os.walk('.')調用遍歷了倉庫中所有文件。接下來,我們檢查它的拓展名,如果是.pyc就刪除它。
通過post-checkout鉤子,你還可以根據你切換的分支來來更改工作目錄。比如說,你可以在代碼庫外面使用一個插件分支來儲存你所有的插件。如果這些插件需要很多二進制文件而其他分支不需要,你可以選擇只在插件分支上build。
pre-rebase
pre-rebase鉤子在git rebase發生更改之前運行,確保不會有什么糟糕的事情發生。
這個鉤子有兩個參數:frok之前的上游分支,將要rebase的下游分支。如果rebase當前分支則第二個參數為空。以非0狀態退出會放棄這次rebase。
比如說,如果你想徹底禁用rebase操作,你可以使用下面的pre-rebase腳本:
#!/bin/sh # 禁用所有rebase echo "pre-rebase: Rebasing is dangerous. Don't do it." exit 1
每次運行git rebase,你都會看到下面的信息:
pre-rebase: Rebasing is dangerous. Don't do it. The pre-rebase hook refused to rebase.
內置的pre-rebase.sample腳本是一個更復雜的例子。它在何時阻止rebase這方面更加智能。它會檢查你當前的分支是否已經合并到了下一個分支中去(也就是主分支)。如果是的話,rebase可能會遇到問題,腳本會放棄這次rebase。
服務端鉤子
服務端鉤子和本地鉤子幾乎一樣,只不過它們存在于服務端的倉庫中(比如說中心倉庫,或者開發者的公共倉庫)。當和官方倉庫連接時,其中一些可以用來拒絕一些不符合規范的提交。
這節中我們要討論下面三個服務端鉤子:
- pre-receive
- update
- post-receive
這些鉤子都允許你對git push的不同階段做出響應。
服務端鉤子的輸出會傳送到客戶端的控制臺中,所以給開發者發送信息是很容易的。但你要記住這些腳本在結束完之前都不會返回控制臺的控制權,所以你要小心那些長時間運行的操作。
pre-receive
pre-receive鉤子在有人用git push向倉庫推送代碼時被執行。它只存在于遠端倉庫中,而不是原來的倉庫中。
這個鉤子在任意引用被更新錢被執行,所以這是強制推行開發規范的好地方。如果你不喜歡推送的那個人(多大仇= =),提交信息的格式,或者提交的更改,你都可以拒絕這次提交。雖然你不能阻止開發者寫出糟糕的代碼,但你可以用pre-receive防止這些代碼流入官方的代碼庫。
這個腳本沒有參數,但每一個推送上來的引用都會以下面的格式傳入腳本的單獨一行:
<old-value> <new-value> <ref-name>
你可以看到這個鉤子做了非常簡單的事,就是讀取推送上來的引用并且把它們打印出來。
#!/usr/bin/env python import sys import fileinput # 讀取用戶試圖更新的所有引用 for line in fileinput.input(): print "pre-receive: Trying to push ref: %s" % line # 放棄推送 # sys.exit(1)
這和其它鉤子相比略微有些不同,因為信息是通過標準輸入而不是命令行傳入的。在遠端倉庫的.git/hooks中加上這個腳本,推送到master分支,你會看到下面這些信息打印出來:
b6b36c697eb2d24302f89aa22d9170dfe609855b 85baa88c22b52ddd24d71f05db31f4e46d579095 refs/heads/master
你可以用SHA1哈希字串,或者底層的Git命令,來檢查將要引入的更改。一些常見的使用包括:
- 拒絕將上游分支rebase的更改
- 防止錯綜復雜的合并(非快速向前,會造成項目歷史非線性)
- 檢查用戶是否有正確的權限來做這些更改(大多用于中心化的Git工作流中)
- 如果多個引用被推送,在pre-receive中返回非0狀態,拒絕所有提交。如果你想一個個接受或拒絕分支,你需要使用update鉤子
update
update鉤子在pre-receive之后被調用,用法也差不多。它也是在實際更新前被調用的,但它可以分別被每個推送上來的引用分別調用。也就是說如果用戶嘗試推送到4個分支,update會被執行4次。和pre-receive不一樣,這個鉤子不需要讀取標準輸入。事實上,它接受三個參數:
- 更新的引用名稱
- 引用中存放的舊的對象名稱
- 引用中存放的新的對象名稱
這些信息和pre-receive相同,但因為每次引用都會分別觸發更新,你可以拒絕一些引用而接受另一些。
#!/usr/bin/env python import sys branch = sys.argv[1] old_commit = sys.argv[2] new_commit = sys.argv[3] print "Moving '%s' from %s to %s" % (branch, old_commit, new_commit) # 只放棄當前分支的推送 # sys.exit(1)
上面這個鉤子簡單地輸出了分支和新舊提交的哈希字串。當你向遠程倉庫推送超過一個分支時,你可以看到每個分支都有輸出。
post-receive
post-receive鉤子在成功推送后被調用,適合用于發送通知。對很多工作流來說,這是一個比post-commit更好的發送通知的地方,因為這些更改在公共的服務器而不是用戶的本地機器上。給其他開發者發送郵件或者觸發一個持續集成系統都是post-receive常用的操作。
這個腳本沒有參數,但和pre-receive一樣通過標準輸入讀取。
總結
在這篇文章中,我們學習了如果用Git鉤子來修改內部行為,當倉庫中特定的事件發生時接受消息。鉤子是存在于git/hooks倉庫中的普通腳本,因此也非常容易安裝和定制。
我們還看了一些常用的本地和服務端的鉤子。這使得我們能夠介入到整個開發生命周期中去。我們現在知道了如何在創建提交或推送的每個階段執行自定義的操作。有了這些簡單的腳本知識,你就可以對Git倉庫為所欲為了 : )
這篇文章是『git-recipes』的一部分,點擊目錄查看所有章節。
如果你覺得文章對你有幫助,歡迎點擊右上角的Star
或Fork
。
如果你發現了錯誤,或是想要加入協作,請參閱Wiki協作說明。