Chapters ▾ 2nd Edition

7.7 ابزارهای گیت (Git Tools) - بازنشانی به زبان ساده (Reset Demystified)

بازنشانی به زبان ساده (Reset Demystified)

قبل از پرداختن به ابزارهای تخصصی‌تر، بیایید درباره دستورات reset و checkout در گیت صحبت کنیم. این دستورات از گیج‌کننده‌ترین بخش‌های گیت هستند که در ابتدا با آنها مواجه می‌شوید. آن‌ها کارهای زیادی انجام می‌دهند که فهمیدن و به کارگیری درست‌شان به نظر ناامیدکننده می‌رسد. برای درک بهتر، یک استعاره ساده پیشنهاد می‌کنیم.

سه درخت (The Three Trees)

راه ساده‌تر برای فکر کردن درباره reset و checkout این است که گیت را به عنوان مدیر محتوای سه درخت مختلف در نظر بگیریم. در اینجا منظور از «درخت» در واقع «مجموعه‌ای از فایل‌ها» است و نه لزوماً ساختار داده‌ای درختی. در مواردی اندک شاخص (index) دقیقاً مانند یک درخت عمل نمی‌کند، اما برای اهداف ما فعلاً راحت‌تر است که اینگونه به آن نگاه کنیم.

گیت به عنوان یک سیستم در عملیات عادی خود، سه درخت را مدیریت و دستکاری می‌کند:

Tree Role

HEAD

Last commit snapshot, next parent

Index

Proposed next commit snapshot

Working Directory

Sandbox

نشانگر رفرنس (The HEAD)

HEAD اشاره‌گری است به مرجع شاخه فعلی، که خود اشاره‌گری است به آخرین کامیت انجام شده روی آن شاخه. این یعنی HEAD والد کامیت بعدی خواهد بود که ایجاد می‌شود. معمولاً ساده‌ترین تصور این است که HEAD نمایی از آخرین کامیت شما در آن شاخه است.

در واقع، دیدن اینکه آن نمای چگونه است کار آسانی است. در اینجا مثالی از گرفتن فهرست دایرکتوری واقعی و چک‌سام SHA-1 هر فایل در نمای HEAD آمده است:

$ git cat-file -p HEAD
tree cfda3bf379e4f8dba8717dee55aab78aef7f4daf
author Scott Chacon  1301511835 -0700
committer Scott Chacon  1301511835 -0700

initial commit

$ git ls-tree -r HEAD
100644 blob a906cb2a4a904a152...   README
100644 blob 8f94139338f9404f2...   Rakefile
040000 tree 99f1a6d12cb4b6f19...   lib

دستورات cat-file و ls-tree در گیت دستوراتی از نوع «لوله‌کشی» (plumbing) هستند که برای کارهای سطح پایین استفاده می‌شوند و معمولاً در کار روزمره کاربرد ندارند، اما به ما کمک می‌کنند ببینیم چه اتفاقی در اینجا می‌افتد.

شاخص (The Index)

index، کامیت پیشنهادی بعدی شما است. ما این مفهوم را به عنوان «منطقه آماده‌سازی» (Staging Area) گیت نیز می‌شناسیم، زیرا گیت هنگام اجرای git commit به آن نگاه می‌کند.

گیت این شاخص را با فهرستی از تمام محتویات فایل‌هایی که آخرین بار در دایرکتوری کاری شما چک‌اوت شده‌اند و شکل اصلی‌شان پر می‌کند. سپس شما بعضی از این فایل‌ها را با نسخه‌های جدیدشان جایگزین می‌کنید و git commit آن را به درخت کامیت جدید تبدیل می‌کند.

$ git ls-files -s
100644 a906cb2a4a904a152e80877d4088654daad0c859 0	README
100644 8f94139338f9404f26296befa88755fc2598c289 0	Rakefile
100644 47c6340d6459e05787f644c2447d2595f5d3a54b 0	lib/simplegit.rb

در اینجا نیز از git ls-files استفاده می‌کنیم که دستوری پشت صحنه است و به شما نشان می‌دهد شاخص شما در حال حاضر چگونه است.

شاخص از لحاظ فنی ساختار درختی ندارد – در واقع به صورت یک فهرست ساده شده پیاده‌سازی شده است – اما برای اهداف ما به اندازه کافی شبیه به درخت است.

دایرکتوری کاری (The Working Directory)

در نهایت، دایرکتوری کاری شما است (که معمولاً به آن «درخت کاری» هم گفته می‌شود). دو درخت دیگر محتوای خود را به صورت کارآمد اما نامناسب در داخل پوشه .git ذخیره می‌کنند. دایرکتوری کاری آن‌ها را به فایل‌های واقعی باز می‌کند که ویرایش آن‌ها را برای شما بسیار آسان‌تر می‌کند. دایرکتوری کاری را مانند یک جعبه شنی تصور کنید، جایی که می‌توانید تغییرات را قبل از ثبت در منطقه آماده‌سازی (شاخص) و سپس در تاریخچه، آزمایش کنید.

$ tree
.
├── README
├── Rakefile
└── lib
    └── simplegit.rb

1 directory, 3 files

روند کار (The Workflow)

روند معمولی گیت این است که با دستکاری این سه درخت، عکس‌هایی از پروژه‌تان را در حالت‌های بهبود یافته متوالی ذخیره کند.

Git’s typical workflow
نمودار 137. Git’s typical workflow

بیایید این فرآیند را تجسم کنیم: فرض کنید وارد یک دایرکتوری جدید می‌شوید که فقط یک فایل دارد. ما این فایل را نسخه v1 می‌نامیم و آن را به رنگ آبی نشان می‌دهیم. حالا git init را اجرا می‌کنیم که یک مخزن گیت با مرجع HEAD ایجاد می‌کند که به شاخه نوزاد master اشاره دارد.

Newly-initialized Git repository with unstaged file in the working directory
نمودار 138. Newly-initialized Git repository with unstaged file in the working directory

در این مرحله، تنها درخت دایرکتوری کاری محتوا دارد.

حالا می‌خواهیم این فایل را کامیت کنیم، بنابراین از git add استفاده می‌کنیم تا محتویات دایرکتوری کاری را به شاخص کپی کنیم.

File is copied to index on `git add`
نمودار 139. File is copied to index on git add

سپس git commit را اجرا می‌کنیم که محتویات شاخص را به عنوان یک عکس دائمی ذخیره می‌کند، یک شیء کامیت ایجاد می‌کند که به آن عکس اشاره دارد و master را به آن کامیت به‌روزرسانی می‌کند.

The `git commit` step
نمودار 140. The git commit step

اگر git status را اجرا کنیم، هیچ تغییری نخواهیم دید، چون هر سه درخت یکسان هستند.

حالا می‌خواهیم تغییراتی در آن فایل ایجاد کنیم و آن را کامیت کنیم. مجدداً همان روند را طی می‌کنیم؛ ابتدا فایل را در دایرکتوری کاری تغییر می‌دهیم. این نسخه را v2 می‌نامیم و به رنگ قرمز نشان می‌دهیم.

Git repository with changed file in the working directory
نمودار 141. Git repository with changed file in the working directory

اگر همین الان git status را اجرا کنیم، فایل به رنگ قرمز و زیر عنوان «Changes not staged for commit» نمایش داده می‌شود، چون وضعیت آن در شاخص و دایرکتوری کاری متفاوت است. سپس git add را روی آن اجرا می‌کنیم تا آن را به شاخص اضافه کنیم.

Staging change to index
نمودار 142. Staging change to index

در این مرحله، اگر git status را اجرا کنیم، فایل به رنگ سبز و زیر عنوان «Changes to be committed» نمایش داده می‌شود، چون شاخص و HEAD متفاوت‌اند – یعنی کامیت پیشنهادی بعدی ما با آخرین کامیت متفاوت است. در نهایت، git commit را اجرا می‌کنیم تا کامیت نهایی شود.

The `git commit` step with changed file
نمودار 143. The git commit step with changed file

اکنون اجرای git status هیچ خروجی‌ای ندارد، چون هر سه درخت دوباره یکسان شده‌اند.

تغییر شاخه‌ها یا کلون کردن هم روند مشابهی دارد. وقتی یک شاخه را چک‌اوت می‌کنید، ابتدا HEAD را به مرجع شاخه جدید تغییر می‌دهد، سپس شاخص را با عکس آن کامیت پر می‌کند، و در نهایت محتویات شاخص را به دایرکتوری کاری کپی می‌کند.

The Role of Reset (نقش دستور Reset)

دستور reset وقتی در این زمینه دیده شود، معنا و مفهوم بیشتری پیدا می‌کند.

برای مثال‌های بعدی فرض کنیم که دوباره file.txt را تغییر داده و سومین بار آن را کامیت کرده‌ایم. پس تاریخچه ما اکنون به این صورت است:

Git repository with three commits
نمودار 144. Git repository with three commits

حالا بیایید دقیقاً بررسی کنیم که وقتی reset را فراخوانی می‌کنید چه اتفاقی می‌افتد. این دستور به طور مستقیم این سه درخت را به روشی ساده و قابل پیش‌بینی دستکاری می‌کند. reset تا سه عملیات پایه‌ای انجام می‌دهد.

مرحله ۱: جابجایی HEAD (Step 1: Move HEAD)

اولین کاری که دستور reset انجام می‌دهد، جابجا کردن چیزی است که HEAD به آن اشاره می‌کند. این با تغییر خود HEAD متفاوت است (کاری که checkout انجام می‌دهد)؛ در واقع reset شاخه‌ای که HEAD به آن اشاره دارد را جابجا می‌کند. این یعنی اگر HEAD روی شاخه master تنظیم شده باشد (یعنی شما در حال حاضر روی شاخه master هستید)، اجرای git reset 9e5e6a4 باعث می‌شود شاخه master به کامیت 9e5e6a4 اشاره کند.

Soft reset
نمودار 145. Soft reset

فرقی ندارد از چه نوع دستوری برای reset با یک کامیت استفاده کنید، این اولین کاری است که همیشه انجام می‌دهد. اگر از گزینه --soft استفاده کنید، همین جا متوقف می‌شود.

حالا یک لحظه به آن نمودار نگاه کنید و ببینید چه اتفاقی افتاد: عملاً آخرین فرمان git commit را لغو کرد. وقتی git commit اجرا می‌کنید، گیت یک کامیت جدید ایجاد می‌کند و شاخه‌ای که HEAD به آن اشاره دارد را به آن منتقل می‌کند. وقتی با reset به HEAD~ (والد HEAD) بازمی‌گردید، شاخه را به موقعیت قبلی‌اش برمی‌گردانید بدون اینکه ایندکس یا دایرکتوری کاری تغییر کند. حالا می‌توانید ایندکس را به‌روزرسانی کنید و دوباره git commit اجرا کنید تا همان کاری را انجام دهید که git commit --amend انجام می‌دهد (ببینید تغییر آخرین کامیت (Changing the Last Commit)).

مرحله ۲: به‌روزرسانی ایندکس (--mixed) (Step 2: Updating the Index (--mixed))

توجه کنید که اگر حالا git status اجرا کنید، تفاوت بین ایندکس و HEAD جدید را به رنگ سبز می‌بینید.

مرحله بعدی کاری است که reset انجام می‌دهد: ایندکس را با محتوای اسنپ‌شات جدیدی که HEAD به آن اشاره می‌کند، به‌روزرسانی می‌کند.

Mixed reset
نمودار 146. Mixed reset

اگر گزینه --mixed را مشخص کنید، reset در همین مرحله متوقف می‌شود. این گزینه همچنین پیش‌فرض است، پس اگر هیچ گزینه‌ای ندهید (مثلاً فقط git reset HEAD~) دستور در همین مرحله متوقف می‌شود.

حالا دوباره به آن نمودار نگاه کنید و ببینید چه اتفاقی افتاد: باز هم آخرین کامیت شما را لغو کرد، اما همچنین همه فایل‌ها را از حالت staged خارج کرد. شما به قبل از اجرای تمام دستورات git add و git commit بازگشتید.

مرحله ۳: به‌روزرسانی دایرکتوری کاری (--hard) (Step 3: Updating the Working Directory (--hard))

سومین کاری که reset انجام می‌دهد این است که دایرکتوری کاری را شبیه ایندکس کند. اگر گزینه --hard را بدهید، دستور تا این مرحله ادامه پیدا می‌کند.

Hard reset
نمودار 147. Hard reset

حالا بیایید فکر کنیم چه اتفاقی افتاد. شما آخرین کامیت، دستورات git add و git commit و همچنین تمام تغییراتی که در دایرکتوری کاری انجام داده بودید را لغو کردید.

مهم است بدانید که این گزینه (--hard) تنها راهی است که دستور reset می‌تواند خطرناک شود و یکی از معدود مواردی است که گیت واقعاً ممکن است داده‌ها را از بین ببرد. هر اجرای دیگری از reset را می‌توان تقریباً به راحتی بازگرداند، اما گزینه --hard نمی‌تواند، چون فایل‌های دایرکتوری کاری را به زور بازنویسی می‌کند. در این مورد خاص، نسخه v3 فایل ما هنوز در یک کامیت در پایگاه داده گیت موجود است و می‌توانیم آن را از طریق reflog بازیابی کنیم، اما اگر آن را کامیت نکرده بودیم، گیت فایل را بازنویسی کرده و بازیابی آن غیرممکن می‌شد.

جمع‌بندی (Recap)

دستور reset این سه درخت را به ترتیب خاصی بازنویسی می‌کند و هر بار که شما بخواهید متوقف می‌شود:

۱. شاخه‌ای که HEAD به آن اشاره دارد را جابجا می‌کند (اگر --soft باشد، همین جا متوقف می‌شود). ۲. ایندکس را شبیه HEAD می‌کند (اگر گزینه‌ای جز --hard نباشد، اینجا متوقف می‌شود). ۳. دایرکتوری کاری را شبیه ایندکس می‌کند.

ریست با مسیر مشخص (Reset With a Path)

این توضیحات مربوط به رفتار پایه‌ای reset بود، اما شما می‌توانید یک مسیر فایل به آن بدهید تا فقط روی آن عمل کند. اگر مسیری مشخص کنید، مرحله اول را رد می‌کند و باقی مراحل را فقط روی فایل یا فایل‌های مشخص شده اجرا می‌کند. این منطقی است — HEAD فقط یک اشاره‌گر است و نمی‌توان به بخشی از یک کامیت و بخشی از کامیت دیگر اشاره کرد. اما ایندکس و دایرکتوری کاری می‌توانند به‌صورت جزئی به‌روزرسانی شوند، پس reset مراحل ۲ و ۳ را ادامه می‌دهد.

فرض کنید دستور git reset file.txt را اجرا می‌کنید. این دستور (چون شما هیچ SHA-1 کامیتی یا شاخه‌ای مشخص نکردید و گزینه --soft یا --hard ندادید) معادل با git reset --mixed HEAD file.txt است که به شکل زیر عمل می‌کند:

۱. شاخه‌ای که HEAD به آن اشاره دارد را جابجا نمی‌کند (رد می‌شود). ۲. ایندکس را شبیه HEAD می‌کند (در اینجا متوقف می‌شود).

پس اساساً فقط فایل file.txt را از HEAD به ایندکس کپی می‌کند.

Mixed reset with a path
نمودار 148. Mixed reset with a path

این در عمل باعث می‌شود فایل از حالت staged خارج شود. اگر به نمودار این دستور نگاه کنید و بفهمید git add چه می‌کند، می‌بینید که دقیقاً عکس هم هستند.

Staging file to index
نمودار 149. Staging file to index

به همین دلیل است که خروجی دستور git status پیشنهاد می‌کند برای unstaged کردن فایل از این دستور استفاده کنید (برای اطلاعات بیشتر ببینید لغو آماده‌سازی یک فایل آماده‌شده (Unstaging a Staged File)).

ما می‌توانیم کاری کنیم که گیت فرض نکند منظور ما "کشیدن داده‌ها از HEAD" است و به‌جای آن یک کامیت خاص مشخص کنیم تا نسخه آن فایل را از آنجا بگیرد. مثلاً می‌توانیم این دستور را بزنیم: git reset eb43bf file.txt.

Soft reset with a path to a specific commit
نمودار 150. Soft reset with a path to a specific commit

این در واقع همان کاری را انجام می‌دهد که اگر محتوای فایل را در شاخه کاری به نسخه v1 بازمی‌گرداندیم، سپس git add را اجرا می‌کردیم و دوباره آن را به نسخه v3 بازمی‌گرداندیم (بدون اینکه واقعاً همه این مراحل را طی کنیم). اگر اکنون git commit را اجرا کنیم، تغییراتی را ثبت می‌کند که فایل را به نسخه v1 بازمی‌گرداند، حتی اگر ما هرگز واقعاً آن نسخه را دوباره در شاخه کاری نداشته باشیم.

نکته جالب دیگر این است که مانند git add، فرمان reset نیز گزینه --patch را می‌پذیرد تا بتوانید محتوا را به صورت تکه به تکه از مرحله استیج خارج یا بازگردانید. پس می‌توانید محتوا را به صورت انتخابی از مرحله استیج خارج یا بازگردانید.

ادغام کامیت‌ها (Squashing)

بگذارید ببینیم چگونه می‌توان با این قدرت تازه یافته کاری جالب انجام داد — ادغام چند کامیت.

فرض کنید سری‌ای از کامیت‌ها دارید با پیام‌هایی مثل «oops.»، «WIP» و «forgot this file». می‌توانید از reset استفاده کنید تا به سرعت و به آسانی آن‌ها را در یک کامیت ادغام کنید و خود را خیلی باهوش نشان دهید. اسکواش کامیت ها (Squashing Commits) راه دیگری برای انجام این کار نشان می‌دهد، اما در این مثال استفاده از reset ساده‌تر است.

مثلاً فرض کنید پروژه‌ای دارید که در اولین کامیت یک فایل وجود دارد، در دومین کامیت فایلی جدید اضافه شده و فایل اول تغییر کرده، و در سومین کامیت فایل اول دوباره تغییر کرده است. کامیت دوم یک کار در حال پیشرفت بوده و می‌خواهید آن را ادغام کنید.

Git repository
نمودار 151. Git repository

می‌توانید دستور git reset --soft HEAD~2 را اجرا کنید تا شاخه HEAD را به یک کامیت قدیمی‌تر (جدیدترین کامیتی که می‌خواهید نگه دارید) ببرید:

Moving HEAD with soft reset
نمودار 152. Moving HEAD with soft reset

سپس به سادگی دوباره git commit را اجرا کنید:

Git repository with squashed commit
نمودار 153. Git repository with squashed commit

حالا می‌بینید که تاریخچه قابل دسترس شما، یعنی همان تاریخی که ارسال می‌کنید، به گونه‌ای شده که انگار یک کامیت با نسخه v1 از file-a.txt داشته‌اید و سپس کامیتی دوم که هم فایل file-a.txt را به نسخه v3 تغییر داده و هم فایل file-b.txt را اضافه کرده است. کامیتی که نسخه v2 فایل را داشت دیگر در تاریخچه نیست.

بررسی تفاوت‌ها (Check It Out)

در نهایت ممکن است بپرسید تفاوت بین checkout و reset چیست. مانند reset، checkout نیز سه درخت را دستکاری می‌کند و کمی متفاوت عمل می‌کند بسته به اینکه به آن مسیر فایل بدهید یا نه.

بدون مسیر فایل (Without Paths)

اجرای git checkout [branch] بسیار شبیه به اجرای git reset --hard [branch] است، به این صورت که هر سه درخت را به حالت [branch] به‌روز می‌کند، اما دو تفاوت مهم دارد.

اول اینکه برخلاف reset --hard، checkout ایمن برای شاخه کاری است؛ یعنی اطمینان حاصل می‌کند که فایل‌هایی که تغییر کرده‌اند پاک نمی‌شوند. در واقع، کمی هوشمندتر است — تلاش می‌کند یک ادغام ساده در شاخه کاری انجام دهد، بنابراین تمام فایل‌هایی که شما تغییر نداده‌اید به‌روزرسانی می‌شوند. از طرف دیگر، reset --hard همه چیز را بدون بررسی جایگزین می‌کند.

تفاوت دوم مهم، نحوه به‌روزرسانی HEAD توسط checkout است. در حالی که reset شاخه‌ای که HEAD به آن اشاره می‌کند را جابجا می‌کند، checkout خودش HEAD را به شاخه دیگری منتقل می‌کند.

برای مثال، فرض کنید شاخه‌های master و develop دارید که به کامیت‌های متفاوت اشاره می‌کنند و اکنون روی develop هستید (پس HEAD به آن اشاره دارد). اگر دستور git reset master را اجرا کنیم، شاخه develop به همان کامیتی اشاره می‌کند که master دارد. اما اگر به جای آن git checkout master را اجرا کنیم، شاخه develop جابجا نمی‌شود، بلکه خود HEAD جابجا می‌شود و حالا به master اشاره می‌کند.

پس در هر دو حالت HEAD را به کامیت A منتقل می‌کنیم، اما چگونگی انجام آن بسیار متفاوت است. reset شاخه‌ای که HEAD به آن اشاره می‌کند را جابجا می‌کند، در حالی که checkout خود HEAD را جابجا می‌کند.

`git checkout` and `git reset`
نمودار 154. git checkout and git reset

با مسیر فایل (With Paths)

روش دیگر اجرای checkout دادن مسیر فایل است که مانند reset، HEAD را جابجا نمی‌کند. این دقیقاً مانند git reset [branch] file عمل می‌کند به این صورت که ایندکس را با آن فایل در آن کامیت به‌روزرسانی می‌کند ولی همچنین فایل را در شاخه کاری بازنویسی می‌کند. این دقیقاً مثل git reset --hard [branch] file (اگر اجازه اجرای آن را داشتید) است — این کار ایمن برای شاخه کاری نیست و HEAD را جابجا نمی‌کند.

همچنین، مانند git reset و git add، checkout نیز گزینه --patch را می‌پذیرد تا بتوانید محتویات فایل را به صورت تکه به تکه بازگردانید.

خلاصه (Summary)

امیدوارم اکنون فرمان reset را بهتر درک کرده و با آن راحت‌تر شده باشید، اما احتمالاً هنوز کمی در مورد تفاوت دقیق آن با checkout گیج هستید و نمی‌توانید همه قواعد مختلف اجراها را به خاطر بسپارید.

در اینجا یک جدول تقلبی است که نشان می‌دهد هر فرمان کدام درخت‌ها را تحت تأثیر قرار می‌دهد. ستون “HEAD” به صورت “REF” خوانده می‌شود اگر آن فرمان مرجع (شاخه) که HEAD به آن اشاره می‌کند را جابجا کند، و “HEAD” اگر خود HEAD را جابجا کند. به ویژه به ستون «آیا ایمن برای شاخه کاری است؟» دقت کنید — اگر نوشته باشد خیر، قبل از اجرای آن فرمان کمی فکر کنید.

HEAD Index Workdir WD Safe?

Commit Level

reset --soft [commit]

REF

NO

NO

YES

reset [commit]

REF

YES

NO

YES

reset --hard [commit]

REF

YES

YES

NO

checkout <commit>

HEAD

YES

YES

YES

File Level

reset [commit] <paths>

NO

YES

NO

YES

checkout [commit] <paths>

NO

YES

YES

NO

scroll-to-top