رفتن به محتوا
LoopX

پشت پرده‌ی Git

توی این درس یاد می‌گیری Git زیر کاپوت چه می‌کند. می‌فهمی پوشه‌ی .git شامل چیست، و کل Git روی چهار نوع شیء ساخته شده: blob (محتوای یک فایل)، tree (یک پوشه)، commit و tag؛ هر شیء با یک شناسه‌ی SHA که از خود محتوایش حساب می‌شود پیدا می‌شود. می‌بینی شاخه فقط یک فایل ۴۱ بایتی است (یک اشاره‌گر)، با git cat-file -p از یک commit تا فایل‌هایش پایین می‌روی، و حتی یک commit را فقط با دستورهای سطح پایین می‌سازی. تمرین: یک commit را با cat-file تا فایل‌هایش دنبال کن.

تا حالا Git را با دستورهایش (add، commit، merge) استفاده کردی. وقتی چیزی غیرمنتظره رخ می‌دهد (detached HEAD، یک commit «گم‌شده»، چیزی که انگار پاک شده)، اگر نمی‌دانی چه چیزی زیرش است حدس می‌زنی. خبر خوب: مدل داخلی Git ساده و کوچک است و تقریباً هر رفتار عجیبش از همان چند ایده نتیجه می‌شود. در این درس همه‌ی آن را مستقیم می‌بینی، با خود فایل‌ها و دستورهای سطح پایین.

تشبیه: انبار با اثر انگشت

Section titled “تشبیه: انبار با اثر انگشت”

Git یک انبار بزرگ است که هر بسته را با اثر انگشت محتوایش برچسب می‌زند. اگر دو بسته‌ی هم‌محتوا بیاید، یک بسته می‌ماند (اثر انگشتشان یکی است). برای پیدا کردن یک بسته فقط اثر انگشتش را لازم داری. «commit» یک بسته‌ی ویژه است که فهرست محتوای یک لحظه (tree) را با اثر انگشت‌ها نگه می‌دارد و اثر انگشت بسته‌ی قبلی را. شاخه فقط یک یادداشت چسبان است: «آخرین بسته‌ی این خط، این اثر انگشت».

یک commit به یک tree (فهرست ریشه) اشاره می‌کند؛ tree به blob ها (محتوای فایل) و tree های زیرپوشه‌ها؛ هر شیء با شناسه‌اش (SHA) پیدا می‌شود. شاخه (main) فقط یک برچسب روی commit است.
شیء چه چیزی را نگه می‌دارد
blob محتوای یک فایل (بدون اسم و بدون دسترسی)
tree فهرست یک پوشه: برای هر ورودی «دسترسی، نوع، شناسه، اسم»؛ ورودی می‌تواند blob یا tree دیگر باشد
commit اشاره به یک tree (عکس کامل پروژه)، اشاره به commit(های) قبلی (parent)، نویسنده، زمان و پیام
tag (annotated) اشاره به یک شیء (معمولاً commit) با نام، سازنده و پیام

یک مخزن تازه می‌سازم و پوشه‌ی .git را قبل و بعد از اولین commit می‌بینم:

Terminal window
mkdir -p ~/gitlab/inside && cd ~/gitlab/inside && git init -q
echo "--- .git یک مخزن خالی:"
ls -A .git
echo "--- تعداد فایل‌های داخل objects:"
find .git/objects -type f | wc -l
mkdir src && echo "# Inside" > README.md && echo "print('hi')" > src/app.py
git add . && git commit -qm "First commit"
echo "--- بعد از اولین commit:"
ls -A .git
echo "--- شیء‌ها:"
find .git/objects -type f | sort | head
خروجی
--- .git یک مخزن خالی:
HEAD
branches
config
description
hooks
info
objects
refs
--- تعداد فایل‌های داخل objects:
0
--- بعد از اولین commit:
COMMIT_EDITMSG
HEAD
branches
config
description
hooks
index
info
logs
objects
refs
--- شیء‌ها:
.git/objects/75/5d89e1c086583a7bef11c39dbdd6859858a3f6
.git/objects/7c/11fbe2228a1c4d2187bedf0e960022995d7977
.git/objects/9f/1b437537a2acdadafd3174f6f0af9c1a04f5e4
.git/objects/c7/8376de751455c850d91200056a979841b60023
.git/objects/e3/be94dbf1b118bf31bf5d612a6bd5429c28699a
در .git کار
objects/ همه‌ی داده: blob ها، tree ها، commit ها (و بعد از gc: بسته‌های pack)
refs/ برچسب‌ها: heads/ (شاخه‌ها)، tags/، remotes/
HEAD اشاره‌گر به شاخه‌ی فعلی (یا یک commit در حالت detached)
index ناحیه‌ی staging (فایل باینری)
config تنظیمات همین مخزن
logs/ reflog
hooks/ اسکریپت‌های hook
COMMIT_EDITMSG، ORIG_HEAD، FETCH_HEAD فایل‌های موقتی عملیات‌های اخیر

بعد از اولین commit، دو blob (برای README.md و src/app.py)، دو tree (ریشه و src) و یک commit ساخته شد: جمعاً ۵ شیء، هر کدام یک فایل در objects/ که اسمش از شناسه‌ی خودش است (دو حرف اول اسم پوشه و بقیه اسم فایل).

مثال ۲: شناسه از محتوا ساخته می‌شود

Section titled “مثال ۲: شناسه از محتوا ساخته می‌شود”

هر شیء با SHA-1 محتوایش (با یک سرآیند) نام‌گذاری می‌شود. این را مستقیم ببین: git hash-object شناسه‌ی یک محتوا را حساب می‌کند و برای اینکه جادو نباشد، با sha1sum همان را دستی بازسازی می‌کنم:

Terminal window
echo "--- شناسه‌ای که Git برای متن «hello» (با یک خط جدید) حساب می‌کند:"
echo "hello" | git hash-object --stdin
echo "--- همان را دستی بساز: SHA-1 از «blob ‹اندازه›\0‹محتوا›»:"
printf 'blob 6\0hello\n' | sha1sum
echo "--- محتوای متفاوت، شناسه‌ی کاملاً متفاوت (فقط یک نویسه فرق دارد):"
echo "Hello" | git hash-object --stdin
خروجی
--- شناسه‌ای که Git برای متن «hello» (با یک خط جدید) حساب می‌کند:
ce013625030ba8dba906f756967f9e9ca394464a
--- همان را دستی بساز: SHA-1 از «blob ‹اندازه›\0‹محتوا›»:
ce013625030ba8dba906f756967f9e9ca394464a -
--- محتوای متفاوت، شناسه‌ی کاملاً متفاوت (فقط یک نویسه فرق دارد):
e965047ad7c57865823c7d992b1d046ea66edf78

دو شناسه‌ی اول دقیقاً یکی هستند: Git واقعاً blob، اندازه، یک بایت صفر و محتوا را به SHA-1 می‌دهد. و عوض‌شدن یک حرف شناسه را کاملاً عوض کرد. نتیجه‌ها: همان محتوا همیشه همان شناسه را دارد (روی هر کامپیوتر، همیشه)، و شناسه اثر انگشت محتوا است. (Git از یک نسخه‌ی SHA-1 مقاوم در برابر برخورد (collision) استفاده می‌کند و یک حالت SHA-256 هم دارد که شناسه‌ها ۶۴ حرفی می‌شوند؛ مخزن‌های SHA-256 هنوز کمتر استفاده می‌شوند:)

Terminal window
git init -q --object-format=sha256 /tmp/lx-sha256
cd /tmp/lx-sha256 && echo "hello" | git hash-object --stdin
rm -rf /tmp/lx-sha256
خروجی
2cf8d83d9ee29543b34a87727421fdecb7e3f3a183d337639025de576db9ebb4

-w شیء را در objects/ می‌نویسد. فایل را پیدا و محتوای واقعی‌اش را بخوان؛ شیء‌ها با zlib فشرده‌اند، پس با cat نمی‌خوانی، ولی با python3 باز می‌کنی:

Terminal window
mkdir -p ~/gitlab/obj && cd ~/gitlab/obj && git init -q
blob=$(echo "hello" | git hash-object -w --stdin)
echo "شناسه: $blob"
echo "--- فایلش در objects (دو حرف اول = پوشه):"
find .git/objects -type f
echo "--- Git چه می‌گوید؟ نوع، اندازه، محتوا:"
git cat-file -t $blob
git cat-file -s $blob
git cat-file -p $blob
echo "--- و محتوای واقعی فایل بعد از باز کردن zlib:"
python3 -c "import zlib,glob; f=glob.glob('.git/objects/ce/*')[0]; print(zlib.decompress(open(f,'rb').read()))"
خروجی
شناسه: ce013625030ba8dba906f756967f9e9ca394464a
--- فایلش در objects (دو حرف اول = پوشه):
.git/objects/ce/013625030ba8dba906f756967f9e9ca394464a
--- Git چه می‌گوید؟ نوع، اندازه، محتوا:
blob
6
hello
--- و محتوای واقعی فایل بعد از باز کردن zlib:
b'blob 6\x00hello\n'

محتوای واقعی فایل: blob 6\0hello\n: همان سرآیند و محتوا که SHA-1 رویش حساب شد. همه‌ی دیتابیس Git همین است: فایل‌هایی با اسم SHA و محتوای فشرده‌ی «نوع اندازه\0محتوا».

مثال ۴: ساختن یک commit فقط با دستورهای سطح پایین

Section titled “مثال ۴: ساختن یک commit فقط با دستورهای سطح پایین”

دستورهایی که تا حالا زدی (add، commit) «porcelain» (چینی‌آلات: رابط تمیز برای آدم) هستند. زیرشان دستورهای «plumbing» (لوله‌کشی: ابزار سطح پایین) هستند. با آن‌ها یک commit را از صفر و بدون git add و git commit می‌سازم؛ همین نشان می‌دهد commit چیست:

Terminal window
mkdir -p ~/gitlab/manual && cd ~/gitlab/manual && git init -q
echo "--- ۱) محتوا را در یک blob بنویس:"
blob=$(echo "hello" | git hash-object -w --stdin); echo "blob: $blob"
echo "--- ۲) آن را به ناحیه‌ی staging با اسم hello.txt معرفی کن (دسترسی 100644 = فایل عادی):"
git update-index --add --cacheinfo 100644 $blob hello.txt
echo "--- ۳) از staging یک tree بساز:"
tree=$(git write-tree); echo "tree: $tree"
git cat-file -p $tree
echo "--- ۴) یک commit بساز که به آن tree اشاره می‌کند:"
commit=$(echo "اولین commit دستی" | git commit-tree $tree); echo "commit: $commit"
git cat-file -p $commit
echo "--- ۵) برچسب شاخه را روی آن بگذار:"
git update-ref refs/heads/main $commit
git log --oneline
echo "--- فایل را هم از تاریخچه بیرون بکش:"
git restore hello.txt && cat hello.txt
git status -s; echo "(پوشه تمیز)"
خروجی
--- ۱) محتوا را در یک blob بنویس:
blob: ce013625030ba8dba906f756967f9e9ca394464a
--- ۲) آن را به ناحیه‌ی staging با اسم hello.txt معرفی کن (دسترسی 100644 = فایل عادی):
--- ۳) از staging یک tree بساز:
tree: aaa96ced2d9a1c8e72c56b253a0e2fe78393feb7
100644 blob ce013625030ba8dba906f756967f9e9ca394464a hello.txt
--- ۴) یک commit بساز که به آن tree اشاره می‌کند:
commit: 26760237eef20b775456a0df931d06368bfb78d6
tree aaa96ced2d9a1c8e72c56b253a0e2fe78393feb7
author Ali <ali@example.com> 1791105171 +0000
committer Ali <ali@example.com> 1791105171 +0000
اولین commit دستی
--- ۵) برچسب شاخه را روی آن بگذار:
2676023 اولین commit دستی
--- فایل را هم از تاریخچه بیرون بکش:
hello
(پوشه تمیز)

پنج قدم: blob ← index ← tree ← commit ← ref. git commit معمولی دقیقاً همین زنجیره را می‌رود. چیزی که به commit می‌گوید «این‌جا شاخه‌ی main است» فقط update-ref بود. و دقت کن چیزی به اسم «تغییر» یا «diff» ذخیره نشد؛ فقط عکس‌ها.

مثال ۵: دنبال کردن یک commit تا فایل‌هایش (تمرین اصلی)

Section titled “مثال ۵: دنبال کردن یک commit تا فایل‌هایش (تمرین اصلی)”

حالا روی یک پروژه‌ی واقعی‌تر (مثال ۱)، از HEAD شروع می‌کنم و فقط با cat-file تا محتوای فایل‌ها پایین می‌روم:

Terminal window
cd ~/gitlab/inside
echo "=== HEAD به کدام commit اشاره می‌کند؟"
cat .git/HEAD
git rev-parse HEAD
echo "=== خود شیء commit:"
git cat-file -p HEAD
echo "=== tree ریشه (از روی خط tree در commit):"
git cat-file -p 'HEAD^{tree}'
echo "=== tree ِ زیرپوشه‌ی src:"
git cat-file -p 'HEAD:src'
echo "=== و محتوای blob ِ src/app.py:"
git cat-file -p 'HEAD:src/app.py'
خروجی
=== HEAD به کدام commit اشاره می‌کند؟
ref: refs/heads/main
e3be94dbf1b118bf31bf5d612a6bd5429c28699a
=== خود شیء commit:
tree c78376de751455c850d91200056a979841b60023
author Ali <ali@example.com> 1791105171 +0000
committer Ali <ali@example.com> 1791105171 +0000
First commit
=== tree ریشه (از روی خط tree در commit):
100644 blob 7c11fbe2228a1c4d2187bedf0e960022995d7977 README.md
040000 tree 755d89e1c086583a7bef11c39dbdd6859858a3f6 src
=== tree ِ زیرپوشه‌ی src:
100644 blob 9f1b437537a2acdadafd3174f6f0af9c1a04f5e4 app.py
=== و محتوای blob ِ src/app.py:
print('hi')

زنجیره: commit (خط tree) ← tree ریشه (ورودی‌هایش: README.md یک blob و src یک tree) ← tree ِ src ← blob ِ app.py. میان‌بُرها: HEAD^{tree} یعنی «tree ِ commit فعلی»، و HEAD:مسیر (مثل HEAD:src/app.py) یعنی «شیء در این مسیر درون commit». هر بار شناسه‌ی tree یا blob را دستی کپی نمی‌کنی. دو چیز دیگر که در خروجی دیدی: 100644 دسترسی یک فایل عادی است، 100755 اجرایی و 040000 پوشه.

مثال ۶: محتوای یکسان، یک شیء

Section titled “مثال ۶: محتوای یکسان، یک شیء”

چون شناسه از محتوا ساخته می‌شود، دو فایل هم‌محتوا یک blob می‌شوند و فایلی که تغییر نکرده در commit بعدی دوباره ذخیره نمی‌شود:

در commit دوم فقط README عوض شده. blob ِ app.py همان قبلی است و هر دو tree به آن اشاره می‌کنند؛ پس هیچ فضای اضافه‌ای مصرف نمی‌شود. (برچسب‌ها فقط توضیح‌اند.)
Terminal window
mkdir -p ~/gitlab/dedup && cd ~/gitlab/dedup && git init -q
echo "same content" > a.txt && cp a.txt b.txt && cp a.txt c.txt
git add . && git commit -qm "Three files, one content"
echo "--- شناسه‌ی blob هر سه فایل (در tree):"
git ls-tree HEAD
echo "--- تعداد شیء‌ها: ۱ blob + ۱ tree + ۱ commit:"
git count-objects -v | grep '^count'
echo "--- commit دوم: فقط a.txt عوض می‌شود:"
echo "changed" > a.txt && git commit -qam "Change a only"
git ls-tree HEAD
echo "--- b.txt و c.txt هنوز به همان blob قبلی اشاره می‌کنند؛ جمع شیء‌ها:"
git count-objects -v | grep '^count'
خروجی
--- شناسه‌ی blob هر سه فایل (در tree):
100644 blob 01e6138faef088714355b81759a88101ce07a1a3 a.txt
100644 blob 01e6138faef088714355b81759a88101ce07a1a3 b.txt
100644 blob 01e6138faef088714355b81759a88101ce07a1a3 c.txt
--- تعداد شیء‌ها: ۱ blob + ۱ tree + ۱ commit:
count: 3
--- commit دوم: فقط a.txt عوض می‌شود:
100644 blob 5ea2ed416fbd4a4cbe227b75fe255dd7fa6bd4d6 a.txt
100644 blob 01e6138faef088714355b81759a88101ce07a1a3 b.txt
100644 blob 01e6138faef088714355b81759a88101ce07a1a3 c.txt
--- b.txt و c.txt هنوز به همان blob قبلی اشاره می‌کنند؛ جمع شیء‌ها:
count: 6

سه فایل هم‌محتوا یک blob دارند (شناسه‌ی یکسان در ls-tree). بعد از commit دوم فقط ۳ شیء جدید اضافه شد: blob ِ جدید برای a.txt، tree ِ جدید و commit؛ ولی b.txt و c.txt همان blob اول را دارند. اشاره‌گر به محتوا، نه کپی محتوا، همان دلیلی است که شاخه‌سازی و commit در Git ارزان است. (و دلیل اینکه جابه‌جا کردن یا تغییر نام یک فایل هم blob جدیدی نمی‌سازد.)

مثال ۷: شاخه، HEAD و tag فقط اشاره‌گرند

Section titled “مثال ۷: شاخه، HEAD و tag فقط اشاره‌گرند”

این‌ها همه فایل‌های کوچک در .git/refs هستند که هش یک شیء را دارند. git for-each-ref همه را فهرست می‌کند:

Terminal window
cd ~/gitlab/inside
git switch -qc feature && git tag -a v1.0 -m "First" main && git switch -q main
echo "--- شاخه، tag و HEAD؛ هر کدام یک فایل:"
cat .git/HEAD
cat .git/refs/heads/main
ls .git/refs/heads .git/refs/tags
echo "--- حجم فایل شاخه (۴۰ نویسه‌ی هش + خط جدید = ۴۱ بایت):"
wc -c < .git/refs/heads/main
echo "--- git for-each-ref:"
git for-each-ref --format='%(objecttype) %(refname)'
echo "--- HEAD یک اشاره‌گر به اشاره‌گر است:"
git symbolic-ref HEAD
خروجی
--- شاخه، tag و HEAD؛ هر کدام یک فایل:
ref: refs/heads/main
e3be94dbf1b118bf31bf5d612a6bd5429c28699a
.git/refs/heads:
feature
main
.git/refs/tags:
v1.0
--- حجم فایل شاخه (۴۰ نویسه‌ی هش + خط جدید = ۴۱ بایت):
41
--- git for-each-ref:
commit refs/heads/feature
commit refs/heads/main
tag refs/tags/v1.0
--- HEAD یک اشاره‌گر به اشاره‌گر است:
refs/heads/main

HEAD معمولاً یک اشاره‌گر نمادین به یک شاخه است (ref: refs/heads/main) و شاخه به یک commit. در حالت detached HEAD فایل HEAD مستقیم یک هش دارد. tag سبک مستقیماً به commit و tag annotated به یک شیء tag اشاره می‌کند (objecttype: tag، درس tag). برای همین ساختن و پاک‌کردن شاخه این‌قدر ارزان است: یک فایل ۴۱ بایتی.

مثال ۸: شناسه‌ی commit را دستی بازسازی کن

Section titled “مثال ۸: شناسه‌ی commit را دستی بازسازی کن”

شناسه‌ی هر commit هم SHA-1 محتوای خود commit است (tree، parent، نویسنده، زمان، پیام) با سرآیند «commit اندازه\0». این هم دستی قابل‌بازسازی است، و همین دلیل ایمن‌بودن تاریخچه است: هر تغییر در هر چیزِ آن، شناسه را عوض می‌کند، و چون شناسه‌ی commit بعدی (فرزند) شامل شناسه‌ی این commit (parent) است، تغییر یکی همه‌ی نسل‌های بعدی را هم عوض می‌کند:

Terminal window
cd ~/gitlab/inside
echo "--- شناسه‌ی commit طبق Git:"
git rev-parse HEAD
echo "--- همان را دستی از محتوای commit:"
size=$(git cat-file commit HEAD | wc -c)
( printf "commit %s\0" "$size"; git cat-file commit HEAD ) | sha1sum
خروجی
--- شناسه‌ی commit طبق Git:
e3be94dbf1b118bf31bf5d612a6bd5429c28699a
--- همان را دستی از محتوای commit:
e3be94dbf1b118bf31bf5d612a6bd5429c28699a -

دو شناسه یکی است. به همین دلیل «بازنویسی تاریخچه» (amend، rebase، reset) در واقع ساختن commit های جدید با شناسه‌های جدید است، نه ویرایش commit قبلی: commit قدیمی تغییرناپذیر است و فقط ممکن است دیگر هیچ برچسبی به آن اشاره نکند (درس نجات).

مثال ۹: بسته‌بندی (pack) و gc

Section titled “مثال ۹: بسته‌بندی (pack) و gc”

اگر هر نسخه‌ی کامل هر فایل را جدا ذخیره کنی، تاریخچه‌ی بزرگ حجم زیادی می‌گیرد. Git شیء‌ها را اول «لَخت» (loose) (هر کدام یک فایل) می‌نویسد، و بعد با git gc (یا خودکار، یا هنگام push) آن‌ها را در یک فایل pack جمع می‌کند که نسخه‌های مشابه را delta (تفاوت نسبت به یک نسخه‌ی دیگر) ذخیره می‌کند. یعنی مدل منطقی عکس کامل است ولی ذخیره‌ی فیزیکی کوچک می‌شود:

Terminal window
mkdir -p ~/gitlab/pack && cd ~/gitlab/pack && git init -q
seq 1 2000 > big.txt && git add big.txt && git commit -qm "c0"
for i in $(seq 1 15); do echo "line $i" >> big.txt; git commit -qam "c$i"; done
echo "--- قبل از gc (شیء‌های لَخت):"
git count-objects -vH | grep -E '^(count|size):'
git gc -q
echo "--- بعد از gc:"
git count-objects -vH | grep -E '^(count|in-pack|packs|size-pack)'
ls .git/objects/pack
echo "--- داخل pack (delta ها):"
git verify-pack -v .git/objects/pack/*.idx | tail -3
خروجی
--- قبل از gc (شیء‌های لَخت):
count: 48
size: 192.00 KiB
--- بعد از gc:
count: 0
in-pack: 48
packs: 1
size-pack: 9.66 KiB
pack-a61f6493f334ba9fafad514e3817be9e56a55196.idx
pack-a61f6493f334ba9fafad514e3817be9e56a55196.pack
pack-a61f6493f334ba9fafad514e3817be9e56a55196.rev
--- داخل pack (delta ها):
non delta: 33 objects
chain length = 1: 15 objects
.git/objects/pack/pack-a61f6493f334ba9fafad514e3817be9e56a55196.pack: ok

۱۶ نسخه‌ی یک فایل ۱۰ کیلوبایتی (که هر بار فقط یک خط عوض می‌شود) قبل از gc حدود ۱۹۰ کیلوبایت و بعد از آن چند کیلوبایت است: chain length = 1: ۱۵ objects یعنی ۱۵ نسخه به‌صورت delta روی نسخه‌ی کامل ذخیره شده‌اند. با فایل‌های .pack (داده) و .idx (فهرست جست‌وجوی سریع). این یعنی «Git تفاوت ذخیره نمی‌کند» فقط در مدل درست است؛ خود بسته‌بندی از delta استفاده می‌کند، ولی هیچ‌وقت به آن وابسته نیستی.

مثال ۱۰: Git خرابی را می‌فهمد

Section titled “مثال ۱۰: Git خرابی را می‌فهمد”

چون هر شیء با اثر انگشت محتوایش نام دارد، هر دستکاری قابل‌تشخیص است. روی یک مخزن آزمایشی یک شیء را عمداً خراب می‌کنم و git fsck (File System ChecK) را می‌زنم:

Terminal window
mkdir -p ~/gitlab/corrupt && cd ~/gitlab/corrupt && git init -q
echo "important" > f && git add f && git commit -qm "One"
obj=$(git rev-parse HEAD:f)
file=.git/objects/${obj:0:2}/${obj:2}
chmod u+w $file && echo "garbage" > $file
echo "--- git fsck:"
git fsck 2>&1 | head -4
echo "--- خواندن همان شیء:"
git cat-file -p $obj 2>&1 | head -2
خروجی
--- git fsck:
error: inflate: data stream error (incorrect header check)
error: unable to unpack header of .git/objects/ea/5b3d7ee315eb4bb4ba741e629f7a048b77c9c1
error: ea5b3d7ee315eb4bb4ba741e629f7a048b77c9c1: object corrupt or missing: .git/objects/ea/5b3d7ee315eb4bb4ba741e629f7a048b77c9c1
missing blob ea5b3d7ee315eb4bb4ba741e629f7a048b77c9c1
--- خواندن همان شیء:
error: inflate: data stream error (incorrect header check)
error: unable to unpack ea5b3d7ee315eb4bb4ba741e629f7a048b77c9c1 header

Git هم موقع باز کردن (inflate: data stream error) و هم در fsck (object corrupt or missing) خرابی را فوراً می‌گیرد. این همان ویژگی‌ای است که تضمین می‌کند تاریخچه بدون هیچ‌کس‌شدنی بی‌خبر دستکاری نمی‌شود. (راه ترمیم: همان شیء را از یک کلون یا remote سالم می‌گیری؛ برای همین «هر کلون یک بکاپ است».)

پشت پرده‌ی ناحیه‌ی staging

Section titled “پشت پرده‌ی ناحیه‌ی staging”

خود ناحیه‌ی staging هم فقط یک فایل است: .git/index، فهرستی از «مسیر ← شناسه‌ی blob» برای commit بعدی. git add blob را می‌نویسد و index را به‌روز می‌کند؛ git write-tree از index یک tree می‌سازد. ببین:

Terminal window
cd ~/gitlab/inside
echo "--- index چه دارد؟ (دسترسی، blob، شماره‌ی stage، مسیر):"
git ls-files --stage
echo "--- یک فایل جدید را add می‌کنم؛ blob همان لحظه ساخته می‌شود:"
echo "new" > new.txt && git add new.txt
git ls-files --stage new.txt
git cat-file -p $(git ls-files --stage new.txt | awk '{print $2}')
git restore --staged new.txt && rm new.txt
خروجی
--- index چه دارد؟ (دسترسی، blob، شماره‌ی stage، مسیر):
100644 7c11fbe2228a1c4d2187bedf0e960022995d7977 0 README.md
100644 9f1b437537a2acdadafd3174f6f0af9c1a04f5e4 0 src/app.py
--- یک فایل جدید را add می‌کنم؛ blob همان لحظه ساخته می‌شود:
100644 3e757656cf36eca53338e520d134963a44f793f8 0 new.txt
new
دستور plumbing کار
git hash-object [-w] [--stdin] فایل شناسه‌ی یک محتوا (و نوشتنش در objects)
git cat-file -t / -s / -p شیء نوع / اندازه / محتوای زیبا
git ls-tree [-r] commit محتوای یک tree
git ls-files --stage محتوای index
git update-index --add --cacheinfo حالت blob مسیر افزودن به index بدون فایل
git write-tree از index یک tree بساز
git commit-tree tree [-p parent] یک commit از یک tree بساز
git update-ref ref commit برچسب را روی یک commit بگذار
git rev-parse نام شناسه‌ی کامل یک اسم
git symbolic-ref HEAD شاخه‌ی فعلی
git for-each-ref فهرست همه‌ی ref ها
git count-objects -v / git verify-pack -v آمار شیء‌ها / محتوای pack
git fsck / git gc بررسی سلامت / بسته‌بندی و پاک‌سازی
آدرس معنی
HEAD^{tree} tree ِ commit فعلی
HEAD:مسیر شیء (blob یا tree) در آن مسیر داخل commit
HEAD~2 / HEAD^ دو commit قبل / والد اول
main^{commit} commit ای که این شاخه یا tag به آن اشاره می‌کند

۱) فکر اینکه Git تفاوت ذخیره می‌کند

Section titled “۱) فکر اینکه Git تفاوت ذخیره می‌کند”

مدل Git «عکس» است؛ delta فقط یک بهینه‌سازی فیزیکی در pack است (مثال ۹). اگر بدانی commit یک عکس است، رفتار checkout، diff و … روشن می‌شود (دیف هر بار حساب می‌شود).

۲) ویرایش دستی فایل‌های داخل .git

Section titled “۲) ویرایش دستی فایل‌های داخل .git”

شیء‌ها فشرده‌اند و با SHA بررسی می‌شوند (مثال ۱۰)؛ دستکاری دستی آن‌ها مخزن را خراب می‌کند. فایل‌های ref و config را هم فقط با ابزار (git branch، git config) عوض کن. استثنا: خواندن آن‌ها برای یادگیری (مثل این درس) بی‌خطر است.

index فقط ناحیه‌ی staging است؛ با git reset از آخرین commit بازسازی می‌شود، ولی هر چه staged بود (و هنوز commit نشده) از دست می‌رود.

۴) کپی کردن فقط پوشه‌ی کاری

Section titled “۴) کپی کردن فقط پوشه‌ی کاری”

اگر فقط فایل‌های کاری را کپی کنی و .git را نه، تاریخچه را نداری؛ و اگر فقط .git را کپی کنی، با git restore . فایل‌ها برمی‌گردند. تاریخچه همه‌اش در .git است.

۵) نگرانی از برخورد SHA-1 و فراموشی SHA-256

Section titled “۵) نگرانی از برخورد SHA-1 و فراموشی SHA-256”

یک «برخورد» (دو محتوای متفاوت با یک شناسه) در SHA-1 ساده‌ی قدیمی نشان داده شده؛ ولی Git سال‌هاست از یک نسخه‌ی SHA-1 همراه تشخیص حمله استفاده می‌کند و مسیر مهاجرت به SHA-256 هم دارد (که دیدی). در کار روزمره دغدغه‌ی تو نیست؛ فقط بدان که شناسه‌ها اثر انگشت‌اند، نه رمزنگاری سنگین.

✎ تمرینآسان

برای commit فعلی یک مخزن، نوع و اندازه‌ی شیء HEAD و شیء HEAD^{tree} را با git cat-file بخوان.

دیدن جواب
Terminal window
mkdir -p ~/gitlab/ex1 && cd ~/gitlab/ex1 && git init -q
echo hi > a.txt && git add a.txt && git commit -qm "Add a"
echo "HEAD:" $(git cat-file -t HEAD) $(git cat-file -s HEAD) "بایت"
echo "tree:" $(git cat-file -t 'HEAD^{tree}') $(git cat-file -s 'HEAD^{tree}') "بایت"
echo "blob:" $(git cat-file -t HEAD:a.txt) $(git cat-file -s HEAD:a.txt) "بایت"
خروجی
HEAD: commit 148 بایت
tree: tree 33 بایت
blob: blob 3 بایت
✎ تمرینمتوسط

تمرین اصلی: یک commit را با cat-file تا فایل‌هایش دنبال کن. مخزنی با یک پوشه‌ی docs و دو فایل بساز؛ فقط با git cat-file -p (و هشی که از خروجی قبلی می‌گیری، بدون میان‌بر HEAD:مسیر) از HEAD شروع کن، tree ریشه، tree ِ docs و در پایان محتوای یک فایل را چاپ کن.

دیدن جواب
Terminal window
mkdir -p ~/gitlab/ex2/docs && cd ~/gitlab/ex2 && git init -q
echo "# Project" > README.md && echo "Install: run it" > docs/install.txt
git add . && git commit -qm "Add docs"
echo "== commit:"; git cat-file -p HEAD
tree=$(git cat-file -p HEAD | awk '/^tree/ {print $2}')
echo "== tree ریشه ($tree):"; git cat-file -p $tree
docs=$(git cat-file -p $tree | awk '$4=="docs" {print $3}')
echo "== tree ِ docs ($docs):"; git cat-file -p $docs
blob=$(git cat-file -p $docs | awk '$4=="install.txt" {print $3}')
echo "== blob ($blob):"; git cat-file -p $blob
خروجی
== commit:
tree 189a88d3eafd0da617a35948592fc04252a5e180
author Ali <ali@example.com> 1791105171 +0000
committer Ali <ali@example.com> 1791105171 +0000
Add docs
== tree ریشه (189a88d3eafd0da617a35948592fc04252a5e180):
100644 blob dab306f45e6a154ab0fe50d67298f165cfc75392 README.md
040000 tree 574d081526803382180e7b0770039c3c94289cab docs
== tree ِ docs (574d081526803382180e7b0770039c3c94289cab):
100644 blob b552914cb640fa98ce4cee507ac0c18d112f9cf4 install.txt
== blob (b552914cb640fa98ce4cee507ac0c18d112f9cf4):
Install: run it
✎ تمرینسخت

یک مخزن با دو commit را فقط با دستورهای plumbing بساز (hash-object، update-index، write-tree، commit-tree، update-ref): commit اول با a.txt، commit دوم (با -p به‌عنوان parent) که b.txt هم دارد. ثابت کن git log دو commit نشان می‌دهد، git status تمیز است، و git fsck مشکلی ندارد.

دیدن جواب
Terminal window
mkdir -p ~/gitlab/ex3 && cd ~/gitlab/ex3 && git init -q
a=$(echo "file a" | git hash-object -w --stdin)
git update-index --add --cacheinfo 100644 $a a.txt
t1=$(git write-tree)
c1=$(echo "first (manual)" | git commit-tree $t1)
b=$(echo "file b" | git hash-object -w --stdin)
git update-index --add --cacheinfo 100644 $b b.txt
t2=$(git write-tree)
c2=$(echo "second (manual)" | git commit-tree $t2 -p $c1)
git update-ref refs/heads/main $c2
git restore a.txt b.txt 2>/dev/null; git checkout -q -- . 2>/dev/null
git log --oneline
git status -s; echo "(پوشه تمیز)"
git fsck && echo "fsck: مشکلی نیست"
git ls-tree HEAD
خروجی
7a0321d second (manual)
7f8cc1e first (manual)
(پوشه تمیز)
fsck: مشکلی نیست
100644 blob 4ef30bbfe26431a69c3820d3a683df54d688f2ec a.txt
100644 blob 4f2e6529203aa6d44b5af6e3292c837ceda003f9 b.txt
⚡ بررسی سریع

چرا دو فایل هم‌محتوا در Git فضای ذخیره‌ی اضافه مصرف نمی‌کنند؟

؟ آزمونک
  1. چهار نوع شیء Git؟

  2. شاخه در Git چیست؟

  3. شناسه‌ی یک blob از چه ساخته می‌شود؟

  4. چرا «بازنویسی تاریخچه» در واقع commit جدید می‌سازد؟

  5. git gc و packfile چه می‌کنند؟

  • .git یک پایگاه داده‌ی کلید-مقدار است: objects/ (داده)، refs/ (برچسب‌ها)، HEAD، index، config، logs/.
  • چهار شیء: blob (محتوای فایل)، tree (پوشه: دسترسی، نوع، شناسه، اسم)، commit (tree + parent + نویسنده + پیام)، tag. شناسه = SHA از محتوا (blob اندازه\0محتوا): محتوای یکسان ⇒ شیء یکسان؛ هر تغییر ⇒ شناسه‌ی جدید.
  • commit یک عکس کامل است نه diff؛ فایل‌های تغییرنکرده به همان blob اشاره می‌کنند. شاخه، HEAD و tag فایل‌های کوچک اشاره‌گراند (۴۱ بایت).
  • زنجیره‌ی git commit: blob ← index ← tree ← commit ← ref؛ می‌شود با plumbing (hash-object، update-index، write-tree، commit-tree، update-ref) دستی ساخت.
  • git cat-file -p، HEAD^{tree}، HEAD:مسیر، git ls-tree، git ls-files --stage برای کاوش. gc شیء‌ها را در pack با delta فشرده می‌کند. git fsck هر خرابی را می‌گیرد.
  • «بازنویسی تاریخچه» یعنی commit های جدید با شناسه‌های جدید؛ commit قدیمی تغییرناپذیر است.
برگه‌ی تقلب این درس
دستورکاری که می‌کند
git cat-file -t / -s / -p شیءنوع / اندازه / محتوا
git cat-file -p 'HEAD^{tree}'tree ریشه‌ی commit فعلی
git cat-file -p HEAD:مسیرمحتوای فایل در commit
git ls-tree [-r] HEADفهرست محتوای tree
git ls-files --stageمحتوای index
echo متن | git hash-object [-w] --stdinشناسه (و نوشتن) یک blob
git write-tree / git commit-treeساخت tree و commit
git update-ref refs/heads/main commitبرچسب شاخه
git for-each-refهمه‌ی ref ها
git count-objects -vH / git gcآمار شیء‌ها / بسته‌بندی
git fsckبررسی سلامت