Git --everything-is-local

9.7 Git 內部原理 - 維護及資料復原

維護及資料復原

偶爾,你可能需要進行一些清理工作 ── 如減小一個倉庫的大小,清理導入的倉庫,或是恢復丟失的資料。本節將描述這類使用場景。

維護

Git 會不定時地自動執行稱為「auto gc」的命令。大部分情況下該命令什麼都不處理。不過要是存在太多鬆散物件 (loose object, 不在 packfile 中的物件) 或 packfile,Git 會執行 git gc 命令。gc 指垃圾收集 (garbage collect),此命令會做很多工作:收集所有鬆散物件並將它們存入 packfile,合併這些 packfile 進一個大的 packfile,然後將不被任何 commit 引用並且已存在一段時間 (數月) 的物件刪除。

可以如下手動執行 auto gc 命令:

$ git gc --auto

再次強調,這個命令一般什麼都不幹。如果有 7,000 個左右的鬆散對象或是 50 個以上的 packfile,Git 才會真正觸發 gc 命令。你可以修改配置中的 gc.autogc.autopacklimit 來調整這兩個設定值。

gc 還會將所有引用 (references) 併入一個單獨檔。假設倉庫中包含以下分支和標籤:

$ 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
cac0cab538b970a37ea1e769cbbde608743bc96d refs/heads/experiment
ab1afef80fac8e34258ff41fc1b867c702daa24b refs/heads/master
cac0cab538b970a37ea1e769cbbde608743bc96d refs/tags/v1.0
9585191f37f7b0fb9444f35a9bf50de191beadc2 refs/tags/v1.1
^1a410efbd13591db07496601ebc7a059dd55cfe9

當更新一個引用時,Git 不會修改這個檔,而是在 refs/heads 下寫入一個新檔。當查找一個引用的 SHA 時,Git 首先在 refs 目錄下查找,如果未找到則到 packed-refs 檔中去查找。因此如果在 refs 目錄下找不到一個引用,該引用可能存到 packed-refs 檔中去了。

請留意檔最後以 ^ 開頭的那一行。這表示該行上一行的那個標籤是一個 annotated 標籤,而該行正是那個標籤所指向的 commit 。

資料復原

在使用 Git 的過程中,有時會不小心丟失 commit 資訊。這一般出現在以下情況下:強制刪除了一個分支而後又想重新使用這個分支,hard-reset 了一個分支從而丟棄了分支的部分 commit。如果這真的發生了,有什麼辦法把丟失的 commit 找回來呢?

下面的例子演示了對 test 倉庫 master 分支進行 hard-reset 到一個老版本的 commit 的操作,然後恢復丟失的 commit 。首先查看一下當前的倉庫狀態:

$ git log --pretty=oneline
ab1afef80fac8e34258ff41fc1b867c702daa24b modified repo a bit
484a59275031909e19aadb7c92262719cfcdf19a added repo.rb
1a410efbd13591db07496601ebc7a059dd55cfe9 third commit
cac0cab538b970a37ea1e769cbbde608743bc96d second commit
fdf4fc3344e67ab068f836878b6c4951e3b15f3d first commit

接著將 master 分支移回至中間的一個 commit:

$ git reset --hard 1a410efbd13591db07496601ebc7a059dd55cfe9
HEAD is now at 1a410ef third commit
$ git log --pretty=oneline
1a410efbd13591db07496601ebc7a059dd55cfe9 third commit
cac0cab538b970a37ea1e769cbbde608743bc96d second commit
fdf4fc3344e67ab068f836878b6c4951e3b15f3d first commit

這樣就丟棄了最新的兩個 commit ── 包含這兩個 commit 的分支不存在了。現在要做的是找出最新的那個 commit 的 SHA,然後添加一個指向它的分支。關鍵在於找出最新的 commit 的 SHA ── 你不大可能記住了這個 SHA,是吧?

通常最快捷的辦法是使用 git reflog 工具。當你 (在一個倉庫下) 工作時,Git 會在你每次修改了 HEAD 時悄悄地將改動記錄下來。當你提交或修改分支時,reflog 就會更新。git update-ref 命令也可以更新 reflog,這是在本章前面的 “Git References” 部分我們使用該命令而不是手工將 SHA 值寫入 ref 文件的理由。任何時間執行 git reflog 命令可以查看當前的狀態:

$ git reflog
1a410ef HEAD@{0}: 1a410efbd13591db07496601ebc7a059dd55cfe9: updating HEAD
ab1afef HEAD@{1}: ab1afef80fac8e34258ff41fc1b867c702daa24b: updating HEAD

可以看到我們 check out 的兩個 commit ,但沒有更多的相關資訊。執行 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

     modified repo a bit

看起來弄丟了的 commit 是底下那個,這樣在那個 commit 上創建一個新分支就能把它恢復過來。比方說,可以在那個 commit (ab1afef) 上創建一個名為 recover-branch 的分支:

$ git branch recover-branch ab1afef
$ git log --pretty=oneline recover-branch
ab1afef80fac8e34258ff41fc1b867c702daa24b modified repo a bit
484a59275031909e19aadb7c92262719cfcdf19a added repo.rb
1a410efbd13591db07496601ebc7a059dd55cfe9 third commit
cac0cab538b970a37ea1e769cbbde608743bc96d second commit
fdf4fc3344e67ab068f836878b6c4951e3b15f3d first commit

酷!這樣有了一個跟原來 master 一樣的 recover-branch 分支,最新的兩個 commit 又找回來了。 接著,假設引起 commit 丟失的原因並沒有記錄在 reflog 中 ── 可以通過刪除 recover-branch 和 reflog 來類比這種情況。這樣最新的兩個 commit 不會被任何東西引用到:

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

因為 reflog 資料是保存在 .git/logs/ 目錄下的,這樣就沒有 reflog 了。現在要怎樣恢復 commit 呢?辦法之一是使用 git fsck 工具,該工具會檢查倉庫的資料完整性。如果指定 --full 選項,該命令顯示所有未被其他物件引用 (指向) 的所有物件:

$ git fsck --full
dangling blob d670460b4b4aece5915caf5c68d12f560a9fe3e4
dangling commit ab1afef80fac8e34258ff41fc1b867c702daa24b
dangling tree aea790b9a58f6cf6f2804eeac9f0abbe9631e4c9
dangling blob 7108f7ecb345ee9d0084193f147cdad4d2998293

本例中,可以從 dangling commit 找到丟失了的 commit。用相同的方法就可以恢復它,即創建一個指向該 SHA 的分支。

移除物件

Git 有許多過人之處,不過有一個功能有時卻會帶來問題:git clone 會將包含每一個檔的所有歷史版本的整個專案下載下來。如果專案包含的僅僅是原始程式碼的話這並沒有什麼壞處,畢竟 Git 可以非常高效地壓縮此類資料。不過如果有人在某個時刻往專案中添加了一個非常大的檔,即便他在後來的提交中將此檔刪掉了,所有的簽出都會下載這個大檔。因為歷史記錄中引用了這個檔,它會一直存在著。

當你將 Subversion 或 Perforce 倉庫轉換導入至 Git 時這會成為一個很嚴重的問題。在此類系統中,(簽出時) 不會下載整個倉庫歷史,所以這種情形不大會有不良後果。如果你從其他系統導入了一個倉庫,或是發覺一個倉庫的尺寸遠超出預計,可以用下面的方法找到並移除大 (尺寸) 物件。

警告:此方法會破壞提交歷史。為了移除對一個大檔的引用,從最早包含該引用的 tree 物件開始之後的所有 commit 物件都會被重寫。如果在剛導入一個倉庫並在其他人在此基礎上開始工作之前這麼做,那沒有什麼問題 ── 否則你不得不通知所有協作者 (貢獻者) 去衍合你新修改的 commit 。

為了演示這點,往 test 倉庫中加入一個大檔,然後在下次提交時將它刪除,接著找到並將這個檔從倉庫中永久刪除。首先,加一個大檔進去:

$ curl http://kernel.org/pub/software/scm/git/git-1.6.3.1.tar.bz2 > git.tbz2
$ git add git.tbz2
$ git commit -am 'added git tarball'
[master 6df7640] added git tarball
 1 files changed, 0 insertions(+), 0 deletions(-)
 create mode 100644 git.tbz2

喔,你並不想往專案中加進一個這麼大的 tar 包。最後還是去掉它:

$ git rm git.tbz2
rm 'git.tbz2'
$ git commit -m 'oops - removed large tarball'
[master da3f30d] oops - removed large tarball
 1 files changed, 0 insertions(+), 0 deletions(-)
 delete mode 100644 git.tbz2

對倉庫進行 gc 操作,並查看佔用了空間:

$ git gc
Counting objects: 21, done.
Delta compression using 2 threads.
Compressing objects: 100% (16/16), done.
Writing objects: 100% (21/21), done.
Total 21 (delta 3), reused 15 (delta 1)

可以執行 count-objects 以查看使用了多少空間:

$ git count-objects -v
count: 4
size: 16
in-pack: 21
packs: 1
size-pack: 2016
prune-packable: 0
garbage: 0

size-pack 是以 KB 為單位表示的 packfiles 的大小,因此已經使用了 2MB 。而在這次提交之前僅用了 2K 左右 ── 顯然在這次提交時刪除檔並沒有真正將其從歷史記錄中刪除。每當有人複製這個倉庫去取得這個小專案時,都不得不複製所有 2MB 資料,而這僅僅因為你曾經不小心加了個大檔。讓我們來解決這個問題。

首先要找出這個檔。在本例中,你知道是哪個文件。假設你並不知道這一點,要如何找出哪個 (些) 文件佔用了這麼多的空間?如果執行 git gc,所有物件會存入一個 packfile 檔;執行另一個底層命令 git verify-pack 以識別出大物件,對輸出的第三欄資訊即檔案大小進行排序,還可以將輸出定向(pipe)到 tail 命令,因為你只關心排在最後的那幾個最大的檔:

$ git verify-pack -v .git/objects/pack/pack-3f8c0...bb.idx | sort -k 3 -n | tail -3
e3f094f522629ae358806b17daf78246c27c007b blob   1486 734 4667
05408d195263d853f09dca71d55116663690c27c blob   12908 3478 1189
7a9eb2fba2b1811321254ac360970fc169ba2330 blob   2056716 2056872 5401

最底下那個就是那個大檔:2MB 。要查看這到底是哪個檔,可以使用第 7 章中已經簡單使用過的 rev-list 命令。若給 rev-list 命令傳入 --objects 選項,它會列出所有 commit SHA 值,blob SHA 值及相應的檔路徑。可以這樣查看 blob 的檔案名:

$ git rev-list --objects --all | grep 7a9eb2fb
7a9eb2fba2b1811321254ac360970fc169ba2330 git.tbz2

接下來要將該檔從歷史記錄的所有 tree 中移除。很容易找出哪些 commit 修改了這個檔:

$ git log --pretty=oneline --branches -- git.tbz2
da3f30d019005479c99eb4c3406225613985a1db oops - removed large tarball
6df764092f3e7c8f5f94cbe08ee5cf42e92a0289 added git tarball

必須重寫從 6df76 開始的所有 commit 才能將檔從 Git 歷史中完全移除。這麼做需要用到第 6 章中用過的 filter-branch 命令:

$ git filter-branch --index-filter \
   'git rm --cached --ignore-unmatch git.tbz2' -- 6df7640^..
Rewrite 6df764092f3e7c8f5f94cbe08ee5cf42e92a0289 (1/2)rm 'git.tbz2'
Rewrite da3f30d019005479c99eb4c3406225613985a1db (2/2)
Ref 'refs/heads/master' was rewritten

--index-filter 選項類似於第 6 章中使用的 --tree-filter 選項,但這裡不是傳入一個命令去修改磁碟上 checked out 的檔,而是修改暫存區域或索引。不能用 rm file 命令來刪除一個特定檔,而是必須用 git rm --cached 來刪除它 ── 也就是說,從索引而不是從磁碟上刪除它。這樣做是出於速度考慮 ── 由於 Git 在執行你的 filter 之前無需將所有版本簽出到磁片上,這個操作會快得多。也可以用 --tree-filter 來完成相同的操作。git rm--ignore-unmatch 選項指定當你試圖刪除的內容並不存在時不顯示錯誤。最後,因為你清楚問題是從哪個 commit 開始的,使用 filter-branch 重寫自 6df7640 這個 commit 開始的所有歷史記錄。不這麼做的話會重寫所有歷史記錄,花費不必要的更多時間。

現在歷史記錄中已經不包含對那個檔的引用了。不過 reflog 以及執行 filter-branch 時 Git 往 .git/refs/original 添加的一些 refs 中仍有對它的引用,因此需要將這些引用刪除並對倉庫進行 repack 操作。在進行 repack 前需要將所有對這些 commits 的引用去除:

$ rm -Rf .git/refs/original
$ rm -Rf .git/logs/
$ git gc
Counting objects: 19, done.
Delta compression using 2 threads.
Compressing objects: 100% (14/14), done.
Writing objects: 100% (19/19), done.
Total 19 (delta 3), reused 16 (delta 1)

看一下節省了多少空間。

$ git count-objects -v
count: 8
size: 2040
in-pack: 19
packs: 1
size-pack: 7
prune-packable: 0
garbage: 0

repack 後倉庫的大小減小到了 7K ,遠小於之前的 2MB 。從 size 值可以看出大檔物件還在鬆散物件中,其實並沒有消失,不過這沒有關係,重要的是在再進行推送或複製,這個物件不會再傳送出去。如果真的要完全把這個物件刪除,可以運行 git prune --expire 命令。