Chapters ▾ 2nd Edition

10.7 (Git Internals) - نگهداری و بازیابی داده‌ها (Maintenance and Data Recovery)

نگهداری و بازیابی داده‌ها (Maintenance and Data Recovery)

گاهی اوقات لازم است کمی پاک‌سازی انجام دهید — مثلاً مخزنی را فشرده‌تر کنید، یک مخزن وارد شده را مرتب کنید یا کار از دست‌رفته‌ای را بازیابی کنید. این بخش به برخی از این سناریوها می‌پردازد.

نگهداری (Maintenance)

گاهی Git به‌طور خودکار فرمانی به نام "auto gc" را اجرا می‌کند. در بیشتر مواقع این فرمان کاری انجام نمی‌دهد. با این حال، اگر شیءهای جدا (شیءهایی که در یک packfile نیستند) زیاد شوند یا تعداد packfileها بیش از حد شود، Git یک فرمان کامل git gc را اجرا می‌کند. "gc" مخفف garbage collect است و این فرمان چند کار انجام می‌دهد: همهٔ شیءهای جدا را جمع‌آوری کرده و در packfileها قرار می‌دهد، packfileها را در یک packfile بزرگ‌تر تجمیع می‌کند، و اشیائی را که از هیچ commitی قابل دسترسی نیستند و چند ماهه شده‌اند حذف می‌کند.

می‌توانید به‌صورت دستی auto gc را این‌گونه اجرا کنید:

$ git gc --auto

باز هم، معمولاً این کار فایده‌ای ندارد. برای اینکه Git واقعا فرمان gc را اجرا کند باید در حدود ۷۰۰۰ شیء جدا یا بیشتر یا بیش از ۵۰ packfile داشته باشید. می‌توانید این محدودیت‌ها را به‌ترتیب با تنظیمات پیکربندی gc.auto و gc.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 شما قرار دارد.

به خط آخر فایل که با ^ شروع می‌شود دقت کنید. این یعنی تگ بالای آن یک annotated tag است و آن خط اشاره به commitای دارد که آن annotated tag به آن اشاره می‌کند.

بازیابی داده‌ها (Data Recovery)

در مقطعی از مسیر یادگیری Git ممکن است به‌طور تصادفی یک commit را از دست بدهید. معمولاً این اتفاق وقتی رخ می‌دهد که یک شاخه را با force حذف می‌کنید در حالی که روی آن کار داشته‌اید و بعداً متوجه می‌شوید که به آن شاخه نیاز داشته‌اید؛ یا وقتی که یک شاخه را با hard reset به عقب برمی‌گردانید و به‌این‌ترتیب commitهایی را رها می‌کنید که از بعضیِ آن‌ها چیزی لازم داشتید. اگر چنین وضعیتی پیش بیاید، چگونه می‌توانید commitهای از دست رفته را بازیابی کنید؟

در اینجا یک مثال نشان داده شده که شاخه master را در مخزن تست شما به یک commit قدیمی‌تر hard-reset می‌کند و سپس commitهای از دست رفته را بازیابی می‌نماید. ابتدا بیایید مروری بر وضعیت فعلی مخزن داشته باشیم:

$ git log --pretty=oneline
ab1afef80fac8e34258ff41fc1b867c702daa24b Modify repo.rb a bit
484a59275031909e19aadb7c92262719cfcdf19a Create 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ها از طریق آن قابل دسترسی باشند. شما باید آخرین شناسه SHA-1 commit را پیدا کنید و سپس شاخه‌ای بسازید که به آن اشاره کند. نکته کار پیدا کردن آخرین SHA-1 است — قطعاً آن را حفظ نکرده‌اید، درست است؟

اغلب سریع‌ترین راه استفاده از ابزاری به نام git reflog است. زمانی که کار می‌کنید، Git بی‌صدا هر بار که HEAD را تغییر می‌دهید، وضعیت آن را ثبت می‌کند. هر بار که commit می‌زنید یا شاخه را تغییر می‌دهید، reflog به‌روزرسانی می‌شود. دستور git update-ref نیز reflog را به‌روزرسانی می‌کند، که این خود دلیل دیگری است برای استفاده از reflog به‌جای نوشتن مستقیم مقدار SHA-1 در فایل‌های ref که در مراجع گیت (Git References) مورد بحث قرار گرفت. هر زمان می‌خواهید می‌توانید با اجرای 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

در اینجا می‌توانیم دو commit را که قبلاً چک‌اوت شده‌اند ببینیم، اما اطلاعات زیادی نمایش داده نشده است. برای دیدن همان اطلاعات به شکلی بسیار مفیدتر، می‌توانید git log -g را اجرا کنید که خروجی معمولی log را برای 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

به نظر می‌رسد commit پایینی همانی است که از دست داده‌اید، پس می‌توانید با ساختن یک شاخه جدید روی آن، آن را بازیابی کنید. برای مثال، می‌توانید شاخه‌ای به نام recover-branch را از آن commit (ab1afef) شروع کنید:

$ 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 اشاره کند.

حذف اشیاء (Removing Objects)

دربارهٔ گیت چیزهای خوب زیادی هست، اما یک ویژگی که می‌تواند مشکل‌ساز شود این است که یک git clone تمام تاریخچهٔ پروژه را دانلود می‌کند، از جمله هر نسخه از هر فایل. این مسئله وقتی کد منبع خالص است مشکل چندانی ایجاد نمی‌کند، زیرا گیت در فشرده‌سازی این داده‌ها بسیار بهینه است. با این حال، اگر در هر نقطه‌ای از تاریخچهٔ پروژه کسی یک فایل بسیار حجیم اضافه کرده باشد، تمام کلون‌های بعدی برای همیشه مجبور به دانلود آن فایل بزرگ خواهند بود، حتی اگر در کمیت بعدی از پروژه حذف شده باشد. از آنجا که آن فایل از تاریخچه قابل دسترسی است، همیشه آنجا خواهد ماند.

این می‌تواند هنگام تبدیل مخازن Subversion یا Perforce به گیت مشکل بزرگی ایجاد کند. چون در آن سیستم‌ها تمام تاریخچه را دانلود نمی‌کنید، این نوع اضافه‌کردن تبعات کمی دارد. اگر از یک سیستم دیگر ایمپورت کرده‌ای یا به هر دلیلی متوجه شده‌ای که مخزن‌ات بسیار بزرگ‌تر از حد انتظار است، این‌جا روش یافتن و حذف اشیاء بزرگ را می‌بینی.

هشدار: این تکنیک برای commit history مخرب است. این روش باعث می‌شود تمام commit objectها از اولین tree‌ای که باید برای حذف یک فایل بزرگ تغییر داده شود، بازنویسی شوند. اگر این کار را بلافاصله بعد از import انجام دهید، قبل از اینکه کسی شروع به base کردن کار خود روی commit کند، مشکلی نیست – در غیر این صورت، باید به تمام contributors اطلاع دهید که آن‌ها باید کارشان را روی commits جدید شما rebase کنند.

برای نشان دادن این موضوع، شما یک فایل بزرگ را به test repository خود اضافه می‌کنید، در commit بعدی آن را حذف می‌کنید، آن را پیدا می‌کنید و سپس به طور دائمی از repository حذف می‌کنید.

ابتدا، یک object بزرگ به history خود اضافه کنید:

$ 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های شما را برحسب kilobyte نشان می‌دهد، پس شما تقریباً ۵MB استفاده کرده‌اید. قبل از آخرین commit، شما چیزی نزدیک به 2K استفاده می‌کردید – مشخص است که حذف فایل از commit قبلی آن را از history حذف نکرد. هر بار که کسی این repository را clone کند، باید کل ۵MB را clone کند فقط برای گرفتن این پروژه‌ی کوچک، چون شما به اشتباه یک فایل بزرگ اضافه کرده‌اید. بیایید از شرش خلاص شویم.

ابتدا باید آن را پیدا کنید. در این مورد، شما می‌دانید آن فایل چیست. اما فرض کنید نمی‌دانستید؛ چطور می‌توانستید تشخیص دهید چه فایل یا فایل‌هایی این‌قدر فضا اشغال کرده‌اند؟ اگر git gc اجرا کنید، همه‌ی objectها داخل یک packfile قرار می‌گیرند؛ می‌توانید objectهای بزرگ را با اجرای دستور plumbing دیگری به نام git verify-pack شناسایی کنید و روی فیلد سوم خروجی (یعنی اندازه فایل) sort کنید. همچنین می‌توانید خروجی را با دستور 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

Object بزرگ در پایین است: ۵MB. برای اینکه بفهمید این فایل چیست، از دستور rev-list استفاده می‌کنید، که قبلاً در بخش اجبار به قالب خاص پیام کامیت (Enforcing a Specific Commit-Message Format) مختصری با آن آشنا شدید. اگر گزینه --objects را به rev-list بدهید، تمام commit SHA-1ها و همچنین blob SHA-1ها را همراه با مسیر فایل‌های مربوطه لیست می‌کند. می‌توانید از این برای پیدا کردن نام blob خود استفاده کنید:

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

حالا، باید این فایل را از همه treeهای گذشته حذف کنید. به‌راحتی می‌توانید ببینید چه commitهایی این فایل را تغییر داده‌اند:

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

شما باید تمام commitهایی که downstream از 7b30847 هستند را بازنویسی کنید تا این فایل به طور کامل از Git history حذف شود. برای این کار، از filter-branch استفاده می‌کنید که قبلاً در بخش بازنویسی تاریخچه (Rewriting History) دیدید:

$ 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 است که در بخش بازنویسی تاریخچه (Rewriting History) استفاده شد، با این تفاوت که به‌جای اجرای دستوری که فایل‌های checkout‌شده روی disk را تغییر می‌دهد، هر بار staging area یا index را تغییر می‌دهید.

به‌جای اینکه یک فایل خاص را با چیزی مثل rm file حذف کنید، باید آن را با git rm --cached حذف کنید – باید از index حذف شود، نه از disk. دلیل این روش، سرعت است – چون Git مجبور نیست هر نسخه را روی disk checkout کند، فرآیند بسیار سریع‌تر خواهد بود. می‌توانید همین کار را با --tree-filter هم انجام دهید اگر بخواهید. گزینه‌ی --ignore-unmatch برای git rm باعث می‌شود اگر الگوی مورد نظر شما وجود نداشت، خطا ندهد. در نهایت، به filter-branch می‌گویید که history را فقط از commit 7b30847 به بعد بازنویسی کند، چون می‌دانید مشکل از آنجا شروع شده. در غیر این صورت، از ابتدا شروع می‌کند و بی‌دلیل زمان بیشتری می‌گیرد.

History شما دیگر حاوی reference به آن فایل نیست. اما reflog شما و مجموعه جدیدی از refs که Git هنگام اجرای filter-branch در .git/refs/original ایجاد کرده همچنان آن را دارند، بنابراین باید آن‌ها را حذف کنید و سپس دیتابیس را دوباره repack کنید. باید هر چیزی که به آن commitهای قدیمی اشاره دارد را پاک کنید قبل از اینکه repack انجام دهید:

$ 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

اندازه‌ی packed repository به 8K کاهش یافته که بسیار بهتر از ۵MB است. از مقدار size می‌توانید ببینید که object بزرگ همچنان در loose objects وجود دارد، پس کاملاً حذف نشده؛ اما هنگام push یا clone بعدی منتقل نخواهد شد، که همین مهم است. اگر واقعاً بخواهید، می‌توانید با اجرای git prune همراه با گزینه‌ی --expire آن object را کاملاً حذف کنید:

$ 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