گیتپیشرفته

گیت واقعاً چطور کار می‌کند: blob، tree، کامیت و ref

پوشهٔ .git را باز کنید و گیت را همان‌طور که هست ببینید: یک انبار اشیاء که هر شیء با هشِ محتوایش نام‌گذاری شده، به‌علاوهٔ چند اشاره‌گرِ جابه‌جاشدنی. یک کامیت را با دست بسازید و بالاخره rebase، reset و reflog را بفهمید.

یک کامیت که به یک tree اشاره می‌کند، آن tree به blobها و یک زیرپوشه اشاره می‌کند، و برنچ main و HEAD به آن کامیت اشاره می‌کنند
در این صفحه

بیشتر آدم‌ها گیت را به شکل فهرستی از دستورها یاد می‌گیرند که باید حفظ شوند. این روش جواب می‌دهد تا وقتی که چیزی خراب شود: یک rebase تاریخچه را طوری بازنویسی می‌کند که انتظارش را نداشتید، یک reset --hard کار یک روزتان را «پاک» می‌کند، یا ناگهان در وضعیت detached HEAD هستید. آن‌وقت دستورها دیگر منطقی به نظر نمی‌رسند.

در لایهٔ زیرین، گیت به‌طرز شگفت‌آوری ساده است. یک انبار کلید-مقدار از اشیاء تغییرناپذیر است، به‌علاوهٔ چند اشاره‌گرِ جابه‌جاشدنی که به آن‌ها ref می‌گویند. وقتی هر دو را با چشم خودتان ببینید، هر دستور گیت خلاصه می‌شود در «چند شیء اضافه کن، بعد یک اشاره‌گر را جابه‌جا کن».

اگر با کار روزمره با برنچ‌ها راحت باشید، بیشترین بهره را از این مقاله می‌برید. اگر نه، اول روش کاری عملی با برنچ‌های گیت برای مبتدی‌ها را بخوانید.

ذخیره‌سازی بر اساس محتوا#

گیت همه‌چیز (محتوای فایل‌ها، پوشه‌ها، کامیت‌ها) را به شکل شیء (object) ذخیره می‌کند و نام هر شیء، هش SHA-1ِ محتوای آن است. از گیت بخواهید یک متن را هش کند:

Bash
$ echo 'hello' | git hash-object --stdin
ce013625030ba8dba906f756967f9e9ca394464a

این دستور را روی هر کامپیوتری و در هر مخزنی اجرا کنید، همین هش را می‌گیرید. گیت یک سرآیند کوچک (کلمهٔ blob، یک فاصله، اندازه بر حسب بایت و یک بایت null) و بعد از آن محتوا را هش می‌کند، و شما می‌توانید بدون گیت هم همین را بازتولید کنید:

Bash
$ printf 'blob 6\0hello\n' | sha1sum
ce013625030ba8dba906f756967f9e9ca394464a  -

این طراحی پیامدهای بزرگی دارد. محتوای یکسان فقط یک بار ذخیره می‌شود، هر چقدر هم که فایل‌ها یا کامیت‌های زیادی آن را داشته باشند. و چون نام به محتوا وابسته است، یک شیء هرگز نمی‌تواند تغییر کند: یک بایت را عوض کنید و یک شیء دیگر با یک نام دیگر خواهید داشت.

چهار نوع شیء#

نوع چه چیزی را ذخیره می‌کند به چه اشاره می‌کند
blob محتوای یک فایل (بدون نام و بدون مجوزها) هیچ‌چیز
tree یک پوشه: نام‌ها، حالت‌ها (mode) و هش ورودی‌هایش blobها و treeهای دیگر
commit یک عکس فوری: نویسنده، ثبت‌کننده، تاریخ و پیام یک tree و کامیت(های) والد
tag یک تگ annotated: سازندهٔ تگ، پیام و امضای اختیاری معمولاً یک کامیت

بیایید یک مخزن کوچک بسازیم و به اشیاء واقعی نگاه کنیم:

Bash
git init -b main demo && cd demo
mkdir src
echo "# Demo" > README.md
echo 'print("hello")' > src/app.py
git add . && git commit -m "First commit"

همین یک کامیت پنج شیء ساخت که به شکل فایل‌های فشرده زیر .git/objects/ ذخیره شده‌اند؛ نام پوشه دو کاراکتر اول هش است و نام فایل بقیهٔ آن:

Bash
$ find .git/objects -type f
.git/objects/08/05455a24b6c68fbc38d0fa5d121f735984285d
.git/objects/11/b15b1a4584b08fa423a57964bdbf018b0da0d5
.git/objects/16/ae6a2afcc370fa1a06bc31edcacc3a7f2c41cc
.git/objects/60/546c2588e9d75d822392d6f296e1fd99f04c96
.git/objects/ef/6ad6746727fa1d0efcf4a020ec3caeda5ccf64

بیشتر هش‌های شما یکی خواهند بود

blobها و treeها فقط به محتوا وابسته‌اند؛ پس اگر همین فایل‌ها را ساخته باشید، آن چهار هش روی کامپیوتر شما هم دقیقاً همین‌ها هستند. اما هش کامیت فرق می‌کند: نام، ایمیل و زمانِ دقیقِ شما در آن هست.

خواندن اشیاء با cat-file#

git cat-file -p هر شیئی را خوانا چاپ می‌کند. از کامیتی شروع کنید که HEAD به آن اشاره می‌کند:

Bash
$ git cat-file -p HEAD
tree 60546c2588e9d75d822392d6f296e1fd99f04c96
author Ada <ada@example.com> 1793256281 +0000
committer Ada <ada@example.com> 1793256281 +0000

First commit

یک کامیت فقط چند خط متن است. دقیقاً به یک tree اشاره می‌کند: عکس فوریِ کل پروژه. آن را دنبال کنید:

Bash
$ git cat-file -p 60546c2
100644 blob 0805455a24b6c68fbc38d0fa5d121f735984285d    README.md
040000 tree ef6ad6746727fa1d0efcf4a020ec3caeda5ccf64    src

$ git cat-file -p ef6ad67
100644 blob 11b15b1a4584b08fa423a57964bdbf018b0da0d5    app.py

$ git cat-file -p 11b15b1
print("hello")

دقت کنید نام فایل‌ها کجا نگه داشته می‌شود: در treeها، نه در blobها. تغییر نام یک فایل یک tree تازه می‌سازد اما از همان blob قبلی استفاده می‌کند.

حالا یک فایل را تغییر دهید و دوباره کامیت کنید:

Bash
$ echo 'print("hello, world")' > src/app.py
$ git commit -am "Greet the world"
$ git cat-file -p HEAD
tree 17b6cd9de955edb28d8a9b0e4668f588ba3c664a
parent 16ae6a2afcc370fa1a06bc31edcacc3a7f2c41cc
...
$ git cat-file -p 'HEAD^{tree}'
100644 blob 0805455a24b6c68fbc38d0fa5d121f735984285d    README.md
040000 tree b6dcf64039da33b42fb025d2cc49c6d070c65686    src

کامیت جدید یک خط parent دارد که به کامیت اول اشاره می‌کند. tree ریشهٔ آن یک tree تازه برای src دارد (چون app.py تغییر کرده)، اما README.md هنوز به همان blobِ قبلی، یعنی 0805455، اشاره می‌کند. فایل‌های تغییرنکرده هیچ هزینه‌ای ندارند.

main HEAD 16ae6a2ffb686060546c217b6cd9ef6ad67b6dcf64080545511b15b19f69d32 ⁧کامیت · ⁦First commit⁩⁩⁧کامیت · ⁦Greet the world⁩⁩⁧tree · ریشهٔ پروژه⁩⁧tree · ریشهٔ پروژه⁩tree · ⁦src/⁩tree · ⁦src/⁩blob · ⁦README.md⁩⁧blob · ⁦app.py⁩ (نسخهٔ ۱)⁩⁧blob · ⁦app.py⁩ (نسخهٔ ۲)⁩ ⁧والد⁩ ⁧مشترک بین هر دو tree⁩
دو کامیت: دومی به والدش و به یک tree تازه اشاره می‌کند؛ blobِ README که تغییر نکرده بین هر دو tree مشترک است، و main و HEAD به جدیدترین کامیت اشاره می‌کنند

عکس فوری، نه تفاوت‌ها

هر کامیت یک عکس فوریِ کامل (یک tree) ذخیره می‌کند، نه فهرستی از تغییرات. git diff و git show تغییرات را در لحظه و با مقایسهٔ دو tree محاسبه می‌کنند. برای صرفه‌جویی در فضای دیسک، git gc بعداً اشیاء را در packfileها بسته‌بندی می‌کند و اشیاء مشابه را به شکل delta فشرده می‌کند؛ اما این فقط جزئیاتِ ذخیره‌سازی است و از دید بقیهٔ گیت پنهان است.

refها: برنچ‌ها فایل‌های ۴۱ بایتی هستند#

اگر اشیاء هرگز تغییر نمی‌کنند، یک برنچ چطور «جلو می‌رود»؟ برنچ‌ها شیء نیستند؛ ref هستند: فایل‌های کوچکی که یک هش کامیت را در خود دارند.

Bash
$ cat .git/HEAD
ref: refs/heads/main

$ cat .git/refs/heads/main
ffb68609991f7ac36c595767d2d91545868364cb

HEAD معمولاً نام برنچ فعلی را نگه می‌دارد و فایلِ برنچ یک هش کامیت را. وقتی کامیت می‌کنید، گیت اشیاء جدید را می‌نویسد و بعد همان یک فایلِ برنچ را با هش جدید بازنویسی می‌کند. از دید برنچ، کامیت کردن فقط همین است.

این چند چیز را توضیح می‌دهد که در غیر این صورت جادو به نظر می‌رسند:

  • ساختن برنچ آنی است، اندازهٔ مخزن هر چقدر که باشد: git branch experiment فقط یک فایل ۴۱ بایتیِ تازه می‌نویسد. (گیت ممکن است بعداً refها را به یک فایل واحد به نام .git/packed-refs منتقل کند که به همان شکل کار می‌کند.)
  • detached HEAD یعنی HEAD به‌جای ref: refs/heads/... مستقیماً یک هش کامیت را در خود دارد. کامیت‌های جدید همچنان کار می‌کنند، اما هیچ برنچی جلو نمی‌رود که آن‌ها را به خاطر بسپارد.
  • تگ‌ها refهایی زیر refs/tags/ هستند که طبق قرارداد هرگز جابه‌جا نمی‌شوند.

یک کامیت را با دست بسازید#

برای اینکه ثابت کنیم چیزی بیشتر از این در کار نیست، بیایید فایلی را بدون git add و git commit کامیت کنیم؛ فقط با دستورهای سطح پایین گیت که به آن‌ها «plumbing» می‌گویند:

Bash
# 1. Store the content as a blob (-w writes it into .git/objects)
blob=$(echo "Made without git add" | git hash-object -w --stdin)

# 2. Put it into the staging area (the index) under a file name
git update-index --add --cacheinfo 100644 "$blob" NOTES.txt

# 3. Turn the index into a tree object
tree=$(git write-tree)

# 4. Create a commit object pointing to that tree, with HEAD as its parent
commit=$(git commit-tree "$tree" -p HEAD -m "Commit made by hand")

# 5. Move the branch pointer
git update-ref refs/heads/main "$commit"
Bash
$ git log --oneline
8e0d264 Commit made by hand
ffb6860 Greet the world
16ae6a2 First commit

یک کامیت کاملاً معمولی. اگر حالا git status را اجرا کنید، می‌بینید NOTES.txt حذف‌شده نشان داده می‌شود: این فایل در کامیت و در index هست، اما ما هرگز آن را در پوشهٔ کاری ننوشتیم. git restore NOTES.txt این را درست می‌کند. این‌ها سه جایی هستند که یک فایل در گیت می‌تواند باشد: پایگاه دادهٔ اشیاء، index و پوشهٔ کاری شما.

این‌ها چه چیزهایی را توضیح می‌دهند#

rebase کامیت‌های جدید می‌سازد. هش هر کامیت، هش والدش را هم در بر می‌گیرد. وقتی یک برنچ را rebase می‌کنید، هر کامیت والد تازه‌ای می‌گیرد؛ پس گیت مجبور است کامیت‌های کاملاً جدید با هش‌های جدید بسازد، حتی اگر تغییرات کد یکسان باشند. کامیت‌های قدیمی هنوز وجود دارند؛ فقط برنچ حالا به جای دیگری اشاره می‌کند. به همین دلیل rebase کردنِ کامیت‌هایی که دیگران قبلاً pull کرده‌اند دردسر می‌سازد: آن‌ها هنوز کامیت‌های قدیمی را دارند.

reset فقط یک اشاره‌گر را جابه‌جا می‌کند. git reset --hard HEAD~2 برنچ فعلی را دو کامیت عقب می‌برد (و فایل‌های شما را هم با آن هماهنگ می‌کند). خودِ کامیت‌ها پاک نمی‌شوند.

reflog به خاطر دارد اشاره‌گرها کجا بوده‌اند. هر بار که HEAD یا یک برنچ جابه‌جا می‌شود، گیت آن را ثبت می‌کند. پس یک کامیتِ «گم‌شده» معمولاً فقط یک دستور با شما فاصله دارد:

Bash
$ git reset --hard HEAD~2        # oops
$ git reflog
16ae6a2 HEAD@{0}: reset: moving to HEAD~2
8e0d264 HEAD@{1}: ...
$ git reset --hard 8e0d264       # or: git branch rescue 8e0d264

این تور ایمنی تاریخ انقضا دارد

کامیت‌هایی که هیچ برنچ، تگ یا ورودیِ reflogی به آن‌ها نمی‌رسد، بالاخره توسط git gc پاک می‌شوند. به‌طور پیش‌فرض ورودی‌های reflog برای کامیت‌های دست‌نیافتنی بعد از ۳۰ روز منقضی می‌شوند (و برای بقیه بعد از ۹۰ روز). کار گم‌شده را زود بازیابی کنید و هرگز فرض نکنید کامیتی که push نشده تا ابد امن است.

یکپارچگی در خودِ ساختار است. چون هش هر کامیت tree آن را پوشش می‌دهد و هش هر tree، blobهایش را، یک هش کامیت ضامنِ کل تاریخچهٔ پشت سرش است. یک بایت را در هر جای گذشته عوض کنید تا همهٔ هش‌های بعد از آن عوض شوند. (گیت دقیقاً به همین دلیل به‌تدریج از SHA-1 به SHA-256 مهاجرت می‌کند؛ همین حالا هم می‌توانید آن را با git init --object-format=sha256 امتحان کنید.)

دستورهای plumbingِ ارزشمند#

دستور چه کاری می‌کند
git cat-file -t <obj> نوع یک شیء را نشان می‌دهد
git cat-file -p <obj> یک شیء را خوانا چاپ می‌کند
git ls-tree -r HEAD همهٔ فایل‌های tree یک کامیت را به‌صورت بازگشتی فهرست می‌کند
git rev-parse main یک نام (برنچ، تگ، HEAD~2…) را به هش تبدیل می‌کند
git hash-object -w <file> یک فایل را به‌عنوان blob ذخیره می‌کند
git update-ref <ref> <hash> یک ref را به یک کامیت اشاره می‌دهد
git count-objects -vH نشان می‌دهد چند شیء وجود دارد و چقدر فضا گرفته‌اند

جمع‌بندی#

  • گیت یک انبار اشیاء بر اساس محتوا است: نام هر شیء هش محتوای آن است، پس اشیاء هرگز تغییر نمی‌کنند.
  • blobها محتوای فایل‌ها را نگه می‌دارند، treeها پوشه‌ها و نام‌ها را، و کامیت‌ها به یک tree و والد(های) خود اشاره می‌کنند.
  • کامیت‌ها عکس فوری هستند؛ فایل‌های تغییرنکرده به‌رایگان بین آن‌ها مشترک‌اند.
  • برنچ‌ها، تگ‌ها و HEAD همه ref هستند: فایل‌های کوچکی که یک هش یا نامِ یک ref دیگر را در خود دارند.
  • rebase، reset و amend اشیاء جدید می‌سازند و اشاره‌گرها را جابه‌جا می‌کنند. تاریخچه را در جا ویرایش نمی‌کنند، و به همین دلیل reflog معمولاً می‌تواند همه‌چیز را برگرداند.

دفعهٔ بعد که یک دستور گیت غافلگیرتان کرد، دو سؤال بپرسید: چه اشیائی ساخت، و کدام اشاره‌گر را جابه‌جا کرد؟