章節 ▾ 第二版

10.7 Git 內部原理 - 維護與資料恢復

維護與資料恢復

有時,你可能需要進行一些清理工作——讓倉庫更緊湊,清理匯入的倉庫,或者恢復丟失的工作。本節將涵蓋其中一些場景。

維護

Git 會偶爾自動執行一個名為“auto gc”的命令。大多數時候,這個命令什麼也不做。但是,如果存在過多的鬆散物件(不在 packfile 中的物件)或過多的 packfile,Git 就會啟動一個完整的 git gc 命令。“gc”代表垃圾收集(garbage collect),該命令執行多項操作:它收集所有鬆散物件並將其放入 packfile 中,它將多個 packfile 合併成一個大的 packfile,並刪除那些無法從任何提交中訪問且已有幾個月歷史的物件。

你可以手動執行 auto gc,如下所示

$ git gc --auto

同樣,這通常什麼也不做。你必須有大約 7,000 個或更多的鬆散物件,或者超過 50 個 packfile,Git 才會真正執行 gc 命令。你可以分別透過 gc.autogc.autopacklimit 配置設定來修改這些限制。

gc 還會做的另一件事是將你的引用打包成一個檔案。假設你的倉庫包含以下分支和標籤

$ find .git/refs -type f
.git/refs/heads/experiment
.git/refs/heads/master
.git/refs/tags/v1.0
.git/refs/tags/v1.1

如果你執行 git gc,你的 refs 目錄中將不再有這些檔案。Git 為了效率會將它們移動到一個名為 .git/packed-refs 的檔案中,該檔案看起來像這樣

$ cat .git/packed-refs
# pack-refs with: peeled fully-peeled
cac0cab538b970a37ea1e769cbbde608743bc96d refs/heads/experiment
ab1afef80fac8e34258ff41fc1b867c702daa24b refs/heads/master
cac0cab538b970a37ea1e769cbbde608743bc96d refs/tags/v1.0
9585191f37f7b0fb9444f35a9bf50de191beadc2 refs/tags/v1.1
^1a410efbd13591db07496601ebc7a059dd55cfe9

如果你更新一個引用,Git 不會編輯這個檔案,而是會在 refs/heads 中寫入一個新檔案。要獲取給定引用的適當 SHA-1,Git 會首先在 refs 目錄中檢查該引用,然後作為備用方案檢查 packed-refs 檔案。因此,如果你在 refs 目錄中找不到某個引用,它可能就在你的 packed-refs 檔案中。

請注意檔案的最後一行,它以 ^ 開頭。這意味著正上方是帶附註的標籤,該行是帶附註的標籤指向的提交。

資料恢復

在你使用 Git 的過程中,有時你可能會不小心丟失一個提交。通常,這會發生在你強制刪除一個包含工作但事後發現需要該分支的分支,或者你硬重置(hard-reset)了一個分支,從而放棄了你想要某些內容的提交。假設這種情況發生了,你如何找回你的提交呢?

這裡有一個例子,它將你的測試倉庫中的 master 分支硬重置到一箇舊的提交,然後恢復丟失的提交。首先,讓我們回顧一下你的倉庫目前的狀態

$ git log --pretty=oneline
ab1afef80fac8e34258ff41fc1b867c702daa24b Modify repo.rb a bit
484a59275031909e19aadb7c92262719cfcdf19a Create repo.rb
1a410efbd13591db07496601ebc7a059dd55cfe9 Third commit
cac0cab538b970a37ea1e769cbbde608743bc96d Second commit
fdf4fc3344e67ab068f836878b6c4951e3b15f3d First commit

現在,將 master 分支移回中間的提交

$ git reset --hard 1a410efbd13591db07496601ebc7a059dd55cfe9
HEAD is now at 1a410ef Third commit
$ git log --pretty=oneline
1a410efbd13591db07496601ebc7a059dd55cfe9 Third commit
cac0cab538b970a37ea1e769cbbde608743bc96d Second commit
fdf4fc3344e67ab068f836878b6c4951e3b15f3d First commit

你實際上已經丟失了最上面的兩個提交——你沒有分支可以訪問這些提交。你需要找到最新的提交 SHA-1,然後新增一個指向它的分支。關鍵在於找到那個最新的提交 SHA-1——你不可能記住它,對吧?

通常,最快的方法是使用一個名為 git reflog 的工具。在你工作時,Git 會在你每次改變 HEAD 時悄悄記錄你的 HEAD 是什麼。每當你提交或切換分支時,reflog 都會更新。reflog 也會被 git update-ref 命令更新,這也是使用它而不是僅僅將 SHA-1 值寫入你的引用檔案的另一個原因,正如我們在Git 引用中介紹的。你可以隨時透過執行 git reflog 來檢視你到過哪裡

$ git reflog
1a410ef HEAD@{0}: reset: moving to 1a410ef
ab1afef HEAD@{1}: commit: Modify repo.rb a bit
484a592 HEAD@{2}: commit: Create repo.rb

在這裡我們可以看到我們已經檢出的兩個提交,但是這裡沒有太多的資訊。為了以更有用的方式檢視相同的資訊,我們可以執行 git log -g,它將為你提供 reflog 的正常日誌輸出。

$ git log -g
commit 1a410efbd13591db07496601ebc7a059dd55cfe9
Reflog: HEAD@{0} (Scott Chacon <schacon@gmail.com>)
Reflog message: updating HEAD
Author: Scott Chacon <schacon@gmail.com>
Date:   Fri May 22 18:22:37 2009 -0700

		Third commit

commit ab1afef80fac8e34258ff41fc1b867c702daa24b
Reflog: HEAD@{1} (Scott Chacon <schacon@gmail.com>)
Reflog message: updating HEAD
Author: Scott Chacon <schacon@gmail.com>
Date:   Fri May 22 18:15:24 2009 -0700

       Modify repo.rb a bit

看來最底部的提交是你丟失的那個,所以你可以透過在該提交處建立一個新分支來恢復它。例如,你可以在該提交 (ab1afef) 處啟動一個名為 recover-branch 的分支

$ git branch recover-branch ab1afef
$ git log --pretty=oneline recover-branch
ab1afef80fac8e34258ff41fc1b867c702daa24b Modify repo.rb a bit
484a59275031909e19aadb7c92262719cfcdf19a Create repo.rb
1a410efbd13591db07496601ebc7a059dd55cfe9 Third commit
cac0cab538b970a37ea1e769cbbde608743bc96d Second commit
fdf4fc3344e67ab068f836878b6c4951e3b15f3d First commit

酷——現在你有一個名為 recover-branch 的分支,它位於你 master 分支曾經的位置,使得前兩個提交再次可訪問。接下來,假設你的丟失由於某種原因不在 reflog 中——你可以透過刪除 recover-branch 並刪除 reflog 來模擬這一點。現在前兩個提交無法被任何東西訪問

$ git branch -D recover-branch
$ rm -Rf .git/logs/

由於 reflog 資料儲存在 .git/logs/ 目錄中,你實際上沒有 reflog。此時,你如何恢復該提交?一種方法是使用 git fsck 工具,它會檢查你的資料庫的完整性。如果你使用 --full 選項執行它,它會顯示所有沒有被其他物件指向的物件

$ git fsck --full
Checking object directories: 100% (256/256), done.
Checking objects: 100% (18/18), done.
dangling blob d670460b4b4aece5915caf5c68d12f560a9fe3e4
dangling commit ab1afef80fac8e34258ff41fc1b867c702daa24b
dangling tree aea790b9a58f6cf6f2804eeac9f0abbe9631e4c9
dangling blob 7108f7ecb345ee9d0084193f147cdad4d2998293

在這種情況下,你可以在字串“dangling commit”之後看到你丟失的提交。你可以用同樣的方法恢復它,即新增一個指向該 SHA-1 的分支。

移除物件

Git 有很多優點,但一個可能導致問題的功能是 git clone 會下載專案的整個歷史,包括每個檔案的每個版本。如果整個專案都是原始碼,這沒有問題,因為 Git 經過高度最佳化,可以高效地壓縮這些資料。然而,如果專案歷史中的某個時刻有人添加了一個巨大的檔案,那麼每次克隆都將永遠被迫下載那個大檔案,即使它在下一個提交中就已經從專案中刪除了。因為它可以從歷史中訪問到,所以它將永遠存在。

當你將 Subversion 或 Perforce 倉庫轉換為 Git 時,這可能是一個巨大的問題。因為在這些系統中你不會下載整個歷史,所以這種新增帶來的後果很少。如果你是從其他系統匯入的,或者發現你的倉庫比預期大得多,這裡將介紹如何查詢和刪除大物件。

警告:此技術會破壞你的提交歷史。它會重寫從你需要修改的最小樹算起的所有後續提交物件,以刪除大檔案引用。如果你在匯入後立即執行此操作,在任何人開始基於該提交進行工作之前,你是安全的——否則,你必須通知所有貢獻者,他們必須將他們的工作rebase到你的新提交上。

為了演示,我們將在你的測試倉庫中新增一個大檔案,在下一個提交中將其刪除,然後找到它,並將其從倉庫中永久刪除。首先,向你的歷史中新增一個大物件

$ curl -L https://www.kernel.org/pub/software/scm/git/git-2.1.0.tar.gz > git.tgz
$ git add git.tgz
$ git commit -m 'Add git tarball'
[master 7b30847] Add git tarball
 1 file changed, 0 insertions(+), 0 deletions(-)
 create mode 100644 git.tgz

哎呀——你不想將一個巨大的 tar 包新增到你的專案中。最好把它刪掉

$ git rm git.tgz
rm 'git.tgz'
$ git commit -m 'Oops - remove large tarball'
[master dadf725] Oops - remove large tarball
 1 file changed, 0 insertions(+), 0 deletions(-)
 delete mode 100644 git.tgz

現在,對你的資料庫進行 gc 並檢視你佔用了多少空間

$ git gc
Counting objects: 17, done.
Delta compression using up to 8 threads.
Compressing objects: 100% (13/13), done.
Writing objects: 100% (17/17), done.
Total 17 (delta 1), reused 10 (delta 0)

你可以執行 count-objects 命令來快速檢視你正在使用的空間

$ git count-objects -v
count: 7
size: 32
in-pack: 17
packs: 1
size-pack: 4868
prune-packable: 0
garbage: 0
size-garbage: 0

size-pack 條目是你的 packfile 的大小(以 KB 為單位),所以你使用了將近 5MB。在上次提交之前,你使用的空間接近 2K——顯然,從上一個提交中刪除檔案並沒有將其從你的歷史記錄中移除。每次有人克隆這個倉庫時,他們都必須克隆所有 5MB,僅僅為了獲取這個小專案,因為你不小心添加了一個大檔案。讓我們把它刪除。

首先你必須找到它。在這種情況下,你已經知道是哪個檔案了。但是假設你不知道;你如何識別是哪個或哪些檔案佔用了這麼多空間呢?如果你執行 git gc,所有物件都在一個 packfile 中;你可以透過執行另一個底層命令 git verify-pack 並按輸出的第三個欄位(即檔案大小)排序來識別大物件。你也可以將其透過 tail 命令管道輸出,因為你只對最後幾個最大的檔案感興趣

$ git verify-pack -v .git/objects/pack/pack-29…69.idx \
  | sort -k 3 -n \
  | tail -3
dadf7258d699da2c8d89b09ef6670edb7d5f91b4 commit 229 159 12
033b4468fa6b2a9547a70d88d1bbe8bf3f9ed0d5 blob   22044 5792 4977696
82c99a3e86bb1267b236a4b6eff7868d97489af1 blob   4975916 4976258 1438

大的物件在底部:5MB。要找出它是哪個檔案,你將使用 rev-list 命令,你在強制執行特定的提交訊息格式中簡要使用過它。如果你向 rev-list 傳遞 --objects,它會列出所有提交的 SHA-1 以及與它們關聯的檔案路徑的 blob SHA-1。你可以使用它來查詢你的 blob 的名稱

$ git rev-list --objects --all | grep 82c99a3
82c99a3e86bb1267b236a4b6eff7868d97489af1 git.tgz

現在,你需要將此檔案從你歷史中的所有樹中刪除。你可以輕鬆地檢視哪些提交修改了此檔案

$ git log --oneline --branches -- git.tgz
dadf725 Oops - remove large tarball
7b30847 Add git tarball

你必須重寫 7b30847 之後的所有提交,才能將此檔案從你的 Git 歷史中完全刪除。為此,你將使用 filter-branch,你在重寫歷史中曾使用過它

$ git filter-branch --index-filter \
  'git rm --ignore-unmatch --cached git.tgz' -- 7b30847^..
Rewrite 7b30847d080183a1ab7d18fb202473b3096e9f34 (1/2)rm 'git.tgz'
Rewrite dadf7258d699da2c8d89b09ef6670edb7d5f91b4 (2/2)
Ref 'refs/heads/master' was rewritten

--index-filter 選項與重寫歷史中使用的 --tree-filter 選項類似,不同之處在於,你不是傳遞一個修改磁碟上已檢出檔案的命令,而是每次修改你的暫存區或索引。

你不能像 rm file 那樣刪除特定檔案,而必須使用 git rm --cached 刪除它——你必須從索引中刪除它,而不是從磁碟中刪除。這樣做的原因是速度——因為 Git 無需在執行過濾器之前將每個修訂版本檢出到磁碟,所以這個過程會快得多。如果你願意,可以使用 --tree-filter 完成相同的任務。git rm--ignore-unmatch 選項告訴它,如果嘗試刪除的模式不存在,則不要報錯。最後,你要求 filter-branch 只從 7b30847 提交開始重寫你的歷史,因為你知道問題是從那裡開始的。否則,它將從頭開始,不必要地花費更長的時間。

你的歷史中不再包含對該檔案的引用。但是,你的 reflog 和 Git 在你執行 filter-branch 時在 .git/refs/original 下新增的一組新引用仍然包含它,所以你必須刪除它們,然後重新打包資料庫。在重新打包之前,你需要刪除任何指向那些舊提交的內容

$ rm -Rf .git/refs/original
$ rm -Rf .git/logs/
$ git gc
Counting objects: 15, done.
Delta compression using up to 8 threads.
Compressing objects: 100% (11/11), done.
Writing objects: 100% (15/15), done.
Total 15 (delta 1), reused 12 (delta 0)

讓我們看看你節省了多少空間。

$ git count-objects -v
count: 11
size: 4904
in-pack: 15
packs: 1
size-pack: 8
prune-packable: 0
garbage: 0
size-garbage: 0

打包後的倉庫大小降至 8K,這比 5MB 好得多。從 size 值可以看出,大物件仍然在你的鬆散物件中,所以它還沒有消失;但是它在推送或後續克隆時不會被傳輸,這才是重要的。如果你真的想,可以透過執行帶有 --expire 選項的 git prune 來完全刪除該物件

$ git prune --expire now
$ git count-objects -v
count: 0
size: 0
in-pack: 15
packs: 1
size-pack: 8
prune-packable: 0
garbage: 0
size-garbage: 0
scroll-to-top