Git
章節 ▾ 第二版

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

維護與資料復原

有時候,您可能需要進行一些清理工作 – 讓儲存庫更精簡、清理匯入的儲存庫或復原遺失的工作。本節將涵蓋其中一些情況。

維護

有時候,Git 會自動執行一個名為「auto gc」的命令。大多數時候,此命令不會執行任何操作。但是,如果存在過多的鬆散物件(未在 packfile 中的物件)或過多的 packfile,Git 會啟動完整的 git gc 命令。「gc」代表垃圾回收,而該命令會執行許多操作:它會收集所有鬆散的物件並將它們放置在 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 使用旅程中,有時可能會不小心遺失提交(commit)。通常,這是因為你強制刪除了一個有工作的分支,結果發現你之後還是需要那個分支;或是你硬重設(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 時默默地記錄下來。每次提交或變更分支時,reflog 都會更新。reflog 也會被 git update-ref 指令更新,這也是我們在 Git 參考 中提到的,使用它而不是直接將 SHA-1 值寫入你的參考檔案的原因。你可以透過執行 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 經過高度優化,可以有效地壓縮這些資料。但是,如果有人在你專案歷史記錄中的任何時間點新增了一個巨大的檔案,那麼所有時間的所有 clone 都將被迫下載該大型檔案,即使它在下一次提交中就被從專案中移除。由於它可以從歷史記錄中存取,因此它將永遠存在。

當你將 Subversion 或 Perforce 儲存庫轉換為 Git 時,這可能會是一個很大的問題。因為你不會在這些系統中下載整個歷史記錄,所以這種新增操作不會產生什麼後果。如果你從另一個系統導入,或是發現你的儲存庫比它應該的大小大得多,以下是如何尋找並移除大型物件的方法。

請注意:這種方法會破壞你的提交歷史記錄。 它會重寫你必須修改以移除大型檔案參考的所有提交物件,從最早的樹狀結構開始。如果你在導入後立即執行此操作,並且在任何人開始基於該提交進行工作之前,那就沒問題,否則,你必須通知所有貢獻者,他們必須將他們的工作變基到你的新提交上。

為了演示,你將在你的測試儲存庫中新增一個大型檔案,在下一個提交中將其移除,找到它,然後將其從儲存庫中永久移除。首先,將一個大型物件新增到你的歷史記錄中:

$ 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

糟糕 – 你不希望將一個巨大的 tarball 新增到你的專案中。最好把它移除:

$ 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 – 顯然,從上一次提交中移除該檔案並沒有將它從你的歷史記錄中移除。每次有人 clone 這個儲存庫時,他們都必須 clone 所有 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 指令,你曾在 強制執行特定提交訊息格式 中簡要地使用過它。如果你將 --objects 傳遞給 rev-list,它會列出所有的提交 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 選項,不同之處在於,你不是傳遞一個修改在磁碟上檢出的檔案的指令,而是每次修改你的暫存區或索引。

你必須使用 git rm --cached 而不是像 rm file 這樣的方式來移除特定檔案 – 你必須從索引中移除它,而不是從磁碟中移除。這樣做的原因是速度 – 因為 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 好得多。你可以從大小值中看到,大型物件仍然存在於你的鬆散物件中,所以它沒有消失;但是它不會在推送或後續的 clone 中傳輸,這才是重要的。如果你真的想這麼做,你可以透過執行帶有 --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