Chapters ▾ 2nd Edition

7.6 ابزارهای گیت (Git Tools) - بازنویسی تاریخچه (Rewriting History)

بازنویسی تاریخچه (Rewriting History)

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

در این بخش، خواهید دید چگونه این کارها را انجام دهید تا تاریخچه‌ی کامیت‌های شما پیش از به اشتراک گذاری با دیگران، به شکل دلخواه شما درآید.

یادداشت
Don’t push your work until you’re happy with it

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

تغییر آخرین کامیت (Changing the Last Commit)

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

اگر فقط می‌خواهید پیام آخرین کامیت را اصلاح کنید، کار ساده است:

$ git commit --amend

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

اگر بخواهید محتوای واقعی آخرین کامیت را تغییر دهید، فرآیند اساساً مشابه است — ابتدا تغییراتی که فراموش کرده‌اید را انجام دهید، آن‌ها را استیج کنید، سپس دستور git commit --amend کامیت قبلی را با کامیت جدید و اصلاح شده جایگزین می‌کند.

با این روش باید مراقب باشید چون اصلاح کامیت باعث تغییر SHA-1 آن می‌شود. این مثل یک ری‌بیس خیلی کوچک است — اگر کامیت آخر را قبلاً پوش کرده‌اید، آن را اصلاح نکنید.

نکته
An amended commit may (or may not) need an amended commit message

وقتی کامیت را اصلاح می‌کنید، هم فرصت تغییر پیام و هم محتوای کامیت را دارید. اگر محتوای کامیت را به طور قابل توجهی تغییر می‌دهید، بهتر است پیام کامیت را هم به‌روز کنید تا این تغییرات را منعکس کند.

اما اگر اصلاحات شما خیلی جزئی است (مثلاً رفع یک اشتباه تایپی ساده یا افزودن فایلی که فراموش کرده بودید استیج کنید) و پیام قبلی کامیت مناسب است، می‌توانید فقط تغییرات را اعمال و استیج کنید و بدون باز کردن ویرایشگر به این شکل ادامه دهید:

$ git commit --amend --no-edit

تغییر پیام چندین کامیت (Changing Multiple Commit Messages)

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

می‌توانید ری‌بیس را به صورت تعاملی با افزودن گزینه -i به دستور git rebase اجرا کنید. باید مشخص کنید تا چه اندازه عقب می‌خواهید تاریخچه را بازنویسی کنید با تعیین کامیتی که می‌خواهید ری‌بیس روی آن انجام شود.

مثلاً اگر می‌خواهید پیام سه کامیت آخر یا هر یک از پیام‌های آن‌ها را تغییر دهید، باید والد آخرین کامیتی که می‌خواهید ویرایش کنید را به عنوان آرگومان به git rebase -i بدهید که معمولاً HEAD~2^ یا HEAD~3 است. به خاطر سپردن ~3 آسان‌تر است چون قصد دارید سه کامیت آخر را ویرایش کنید، اما به یاد داشته باشید که در واقع دارید والد کامیتی که می‌خواهید ویرایش کنید را تعیین می‌کنید که چهار کامیت قبل است:

$ git rebase -i HEAD~3

دوباره به خاطر داشته باشید که این یک دستور بازپایه‌گذاری (rebasing) است — هر کامیتی در بازه‌ی HEAD~3..HEAD که پیام آن تغییر کرده باشد و همه‌ی فرزندان آن بازنویسی خواهند شد. هیچ کامیتی که قبلاً به سرور مرکزی فرستاده‌اید را وارد نکنید — انجام این کار باعث سردرگمی سایر توسعه‌دهندگان می‌شود، چون نسخه‌ی جایگزینی از همان تغییر ارائه می‌دهد.

اجرای این دستور فهرستی از کامیت‌ها را در ویرایشگر متن شما نمایش می‌دهد که چیزی شبیه این است:

pick f7f3f6d Change my name a bit
pick 310154e Update README formatting and add blame
pick a5f4a0d Add cat-file

# Rebase 710f0f8..a5f4a0d onto 710f0f8
#
# Commands:
# p, pick <commit> = use commit
# r, reword <commit> = use commit, but edit the commit message
# e, edit <commit> = use commit, but stop for amending
# s, squash <commit> = use commit, but meld into previous commit
# f, fixup <commit> = like "squash", but discard this commit's log message
# x, exec <command> = run command (the rest of the line) using shell
# b, break = stop here (continue rebase later with 'git rebase --continue')
# d, drop <commit> = remove commit
# l, label <label> = label current HEAD with a name
# t, reset <label> = reset HEAD to a label
# m, merge [-C <commit> | -c <commit>] <label> [# <oneline>]
# .       create a merge commit using the original merge commit's
# .       message (or the oneline, if no original merge commit was
# .       specified). Use -c <commit> to reword the commit message.
#
# These lines can be re-ordered; they are executed from top to bottom.
#
# If you remove a line here THAT COMMIT WILL BE LOST.
#
# However, if you remove everything, the rebase will be aborted.
#
# Note that empty commits are commented out

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

$ git log --pretty=format:"%h %s" HEAD~3..HEAD
a5f4a0d Add cat-file
310154e Update README formatting and add blame
f7f3f6d Change my name a bit

توجه کنید که ترتیب کامیت‌ها معکوس است. بازپایه‌گذاری تعاملی (interactive rebase) اسکریپتی به شما می‌دهد که قرار است اجرا شود. این اسکریپت از کامیتی که در خط فرمان مشخص کرده‌اید (HEAD~3) شروع می‌کند و تغییرات هر یک از این کامیت‌ها را از بالا به پایین بازپخش می‌کند. کامیت‌های قدیمی‌تر در بالا قرار دارند، نه جدیدترها، چون اولین کامیتی است که بازپخش می‌شود.

شما باید اسکریپت را طوری ویرایش کنید که در کامیتی که می‌خواهید ویرایش کنید، متوقف شود. برای این کار، کلمه‌ی “pick” را به “edit” تغییر دهید برای هر کامیتی که می‌خواهید اسکریپت بعد از آن متوقف شود. مثلاً، برای تغییر پیام فقط کامیت سوم، فایل را به شکل زیر تغییر می‌دهید:

edit f7f3f6d Change my name a bit
pick 310154e Update README formatting and add blame
pick a5f4a0d Add cat-file

وقتی فایل را ذخیره و از ویرایشگر خارج می‌شوید، گیت شما را به آخرین کامیت در آن فهرست برمی‌گرداند و پیام زیر را در خط فرمان نشان می‌دهد:

$ git rebase -i HEAD~3
Stopped at f7f3f6d... Change my name a bit
You can amend the commit now, with

       git commit --amend

Once you're satisfied with your changes, run

       git rebase --continue

این دستورالعمل‌ها دقیقاً به شما می‌گویند چه کاری انجام دهید. بنویسید:

$ git commit --amend

Change the commit message, and exit the editor. Then, run:

$ git rebase --continue

این دستور دو کامیت دیگر را به‌طور خودکار اعمال می‌کند و کار شما تمام می‌شود. اگر pick را در چند خط به edit تغییر دهید، می‌توانید این مراحل را برای هر کامیتی که به edit تغییر داده‌اید تکرار کنید. هر بار گیت متوقف می‌شود، اجازه می‌دهد کامیت را اصلاح کنید و وقتی تمام کردید ادامه می‌دهد.

مرتب‌سازی مجدد کامیت‌ها (Reordering Commits)

می‌توانید از بازپایه‌گذاری تعاملی برای مرتب‌کردن دوباره یا حذف کامل کامیت‌ها هم استفاده کنید. اگر می‌خواهید کامیت “Add cat-file” را حذف کنید و ترتیب دو کامیت دیگر را تغییر دهید، می‌توانید اسکریپت بازپایه‌گذاری را از این حالت به این شکل تغییر دهید:

pick f7f3f6d Change my name a bit
pick 310154e Update README formatting and add blame
pick a5f4a0d Add cat-file

به این:

pick 310154e Update README formatting and add blame
pick f7f3f6d Change my name a bit

وقتی فایل را ذخیره و از ویرایشگر خارج می‌شوید، گیت شاخه‌ی شما را به والد این کامیت‌ها برمی‌گرداند، سپس 310154e و بعد f7f3f6d را اعمال می‌کند و متوقف می‌شود. شما عملاً ترتیب این کامیت‌ها را تغییر داده و کامیت “Add cat-file” را به طور کامل حذف کرده‌اید.

اسکواش کامیت ها (Squashing Commits)

با ابزار بازپایه‌گذاری تعاملی می‌توانید چندین کامیت را به یک کامیت فشرده کنید. اسکریپت دستورالعمل‌های مفیدی در پیام بازپایه‌گذاری می‌گذارد:

#
# Commands:
# p, pick <commit> = use commit
# r, reword <commit> = use commit, but edit the commit message
# e, edit <commit> = use commit, but stop for amending
# s, squash <commit> = use commit, but meld into previous commit
# f, fixup <commit> = like "squash", but discard this commit's log message
# x, exec <command> = run command (the rest of the line) using shell
# b, break = stop here (continue rebase later with 'git rebase --continue')
# d, drop <commit> = remove commit
# l, label <label> = label current HEAD with a name
# t, reset <label> = reset HEAD to a label
# m, merge [-C <commit> | -c <commit>] <label> [# <oneline>]
# .       create a merge commit using the original merge commit's
# .       message (or the oneline, if no original merge commit was
# .       specified). Use -c <commit> to reword the commit message.
#
# These lines can be re-ordered; they are executed from top to bottom.
#
# If you remove a line here THAT COMMIT WILL BE LOST.
#
# However, if you remove everything, the rebase will be aborted.
#
# Note that empty commits are commented out

اگر به جای “pick” یا “edit”، “squash” را مشخص کنید، گیت تغییر آن کامیت و کامیت مستقیم قبل از آن را اعمال می‌کند و از شما می‌خواهد پیام‌های کامیت‌ها را ادغام کنید. پس، اگر می‌خواهید از این سه کامیت یک کامیت بسازید، اسکریپت را به این شکل تنظیم می‌کنید:

pick f7f3f6d Change my name a bit
squash 310154e Update README formatting and add blame
squash a5f4a0d Add cat-file

وقتی ذخیره و از ویرایشگر خارج می‌شوید، گیت هر سه تغییر را اعمال می‌کند و سپس شما را دوباره به ویرایشگر می‌برد تا پیام‌های سه کامیت را با هم ادغام کنید:

# This is a combination of 3 commits.
# The first commit's message is:
Change my name a bit

# This is the 2nd commit message:

Update README formatting and add blame

# This is the 3rd commit message:

Add cat-file

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

تقسیم یک کامیت (Splitting a Commit)

تقسیم یک کامیت یعنی بازگرداندن آن کامیت و سپس مرحله‌بندی و کامیت کردن بخش‌هایی از آن به تعداد کامیت‌هایی که می‌خواهید داشته باشید. مثلاً فرض کنید می‌خواهید کامیت وسط از سه کامیت را تقسیم کنید. به جای “Update README formatting and add blame”، می‌خواهید آن را به دو کامیت تقسیم کنید: اولی “Update README formatting” و دومی “Add blame”. می‌توانید این کار را در اسکریپت rebase -i با تغییر دستور روی کامیتی که می‌خواهید تقسیم کنید به “edit” انجام دهید:

pick f7f3f6d Change my name a bit
edit 310154e Update README formatting and add blame
pick a5f4a0d Add cat-file

سپس وقتی اسکریپت شما را به خط فرمان می‌رساند، آن کامیت را ریست می‌کنید، تغییرات ریست شده را می‌گیرید و چند کامیت جدید از آن می‌سازید. وقتی ذخیره و از ویرایشگر خارج می‌شوید، گیت شاخه را به والد اولین کامیت در فهرست برمی‌گرداند، اولین کامیت (f7f3f6d) را اعمال می‌کند، دومین را (310154e) اعمال می‌کند و شما را به کنسول می‌برد. در آنجا می‌توانید با دستور git reset HEAD^ ریست ترکیبی روی آن کامیت بزنید که عملاً آن کامیت را undo می‌کند و فایل‌های تغییر یافته را unstaged باقی می‌گذارد. حالا می‌توانید فایل‌ها را مرحله‌بندی و کامیت کنید تا کامیت‌های متعددی ایجاد شود، و وقتی تمام کردید، دستور git rebase --continue را اجرا کنید:

$ git reset HEAD^
$ git add README
$ git commit -m 'Update README formatting'
$ git add lib/simplegit.rb
$ git commit -m 'Add blame'
$ git rebase --continue

گیت آخرین کامیت (a5f4a0d) در اسکریپت را اعمال می‌کند و تاریخچه شما این‌گونه خواهد بود:

$ git log -4 --pretty=format:"%h %s"
1c002dd Add cat-file
9b29157 Add blame
35cfb2b Update README formatting
f7f3f6d Change my name a bit
 این کار باعث تغییر SHA-1 سه کامیت اخیر در لیست شما می‌شود، پس مطمئن شوید که هیچ کامیتی که تغییر کرده و قبلاً به مخزن مشترک ارسال کرده‌اید، در آن لیست ظاهر نشود.
توجه داشته باشید که آخرین کامیت (`f7f3f6d`) در لیست بدون تغییر باقی مانده است.
با وجود اینکه این کامیت در اسکریپت نشان داده شده است، چون با علامت "`pick`" مشخص شده و قبل از هر تغییر ری‌بیس اعمال شده، گیت آن کامیت را دست‌نخورده باقی می‌گذارد.

حذف یک کامیت (Deleting a commit)

اگر می‌خواهید یک کامیت را حذف کنید، می‌توانید با استفاده از اسکریپت rebase -i این کار را انجام دهید. در لیست کامیت‌ها، کلمه “drop” را قبل از کامیتی که می‌خواهید حذف کنید قرار دهید (یا به سادگی آن خط را از اسکریپت ری‌بیس حذف کنید):

pick 461cb2a This commit is OK
drop 5aecc10 This commit is broken

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

اگر در میانه انجام ری‌بیس چنین کاری تصمیم گرفتید که ادامه ندهید، همیشه می‌توانید متوقف شوید. دستور git rebase --abort را وارد کنید تا مخزن شما به حالتی که قبل از شروع ری‌بیس بوده بازگردد.

اگر ری‌بیس را کامل کردید و بعد متوجه شدید که نتیجه مطلوب نیست، می‌توانید با استفاده از git reflog نسخه قبلی شاخه خود را بازیابی کنید. برای اطلاعات بیشتر درباره دستور reflog به بازیابی داده‌ها (Data Recovery) مراجعه کنید.

یادداشت

درو دی‌والت یک راهنمای عملی و کاربردی همراه با تمرین‌ها برای یادگیری استفاده از git rebase تهیه کرده است. می‌توانید آن را در این آدرس پیدا کنید: https://git-rebase.io/

گزینه هسته‌ای: filter-branch (The Nuclear Option: filter-branch)

گزینه دیگری برای بازنویسی تاریخچه وجود دارد که می‌توانید وقتی نیاز به بازنویسی تعداد زیادی کامیت به صورت اسکریپتی دارید، از آن استفاده کنید — مثلاً تغییر آدرس ایمیل خود به صورت سراسری یا حذف یک فایل از هر کامیت. این دستور filter-branch است و می‌تواند بخش‌های بسیار بزرگی از تاریخچه شما را بازنویسی کند، بنابراین احتمالاً نباید از آن استفاده کنید مگر اینکه پروژه شما هنوز عمومی نشده و افراد دیگری روی کامیت‌هایی که می‌خواهید بازنویسی کنید کار نکرده باشند. با این حال، این ابزار می‌تواند بسیار مفید باشد. شما چند کاربرد رایج آن را یاد خواهید گرفت تا با برخی از توانایی‌های آن آشنا شوید.

گوشزد

git filter-branch مشکلات زیادی دارد و دیگر روش توصیه‌شده برای بازنویسی تاریخچه نیست. به جای آن، بهتر است از git-filter-repo استفاده کنید که یک اسکریپت پایتون است و در بیشتر مواردی که معمولاً به filter-branch رجوع می‌کنید، عملکرد بهتری دارد. مستندات و کد منبع آن را می‌توانید در https://github.com/newren/git-filter-repo بیابید.

حذف یک فایل از همه کامیت‌ها (Removing a File from Every Commit)

این مورد نسبتاً رایج است. کسی به اشتباه یک فایل باینری بزرگ را با دستور بی‌فکر git add . کامیت کرده و شما می‌خواهید آن را از همه جا حذف کنید. شاید به اشتباه فایلی که حاوی رمز عبور بوده را کامیت کرده‌اید و می‌خواهید پروژه‌تان را متن‌باز کنید. filter-branch ابزاری است که احتمالاً می‌خواهید برای پاک‌سازی کل تاریخچه خود استفاده کنید. برای حذف فایلی به نام passwords.txt از کل تاریخچه، می‌توانید از گزینه --tree-filter در filter-branch استفاده کنید:

$ git filter-branch --tree-filter 'rm -f passwords.txt' HEAD
Rewrite 6b9b3cf04e7c5686a9cb838c3f36a8cb6a0fc2bd (21/21)
Ref 'refs/heads/master' was rewritten

گزینه --tree-filter دستوری را پس از هر بار چک‌اوت پروژه اجرا کرده و سپس نتایج را دوباره کامیت می‌کند. در این حالت، فایل passwords.txt را از هر اسنپ‌شات حذف می‌کند، چه وجود داشته باشد چه نه. اگر بخواهید همه فایل‌های پشتیبان تصادفی ویرایشگر را حذف کنید، می‌توانید چیزی شبیه به git filter-branch --tree-filter 'rm -f *~' HEAD را اجرا کنید.

می‌توانید روند بازنویسی درخت‌ها و کامیت‌ها را ببینید و سپس اشاره‌گر شاخه را در پایان حرکت دهید. عموماً بهتر است این کار را روی یک شاخه آزمایشی انجام دهید و پس از اطمینان از نتیجه، شاخه master خود را با hard-reset بازنشانی کنید. برای اجرای filter-branch روی همه شاخه‌ها، می‌توانید گزینه --all را به دستور اضافه کنید.

Making a Subdirectory the New Root (تبدیل یک زیرشاخه به ریشه جدید)

فرض کنید از یک سیستم کنترل نسخه دیگر وارد شده‌اید و زیرشاخه‌هایی دارید که معنایی ندارند (مانند trunk، tags و غیره). اگر می‌خواهید زیرشاخه trunk ریشه جدید پروژه در همه کامیت‌ها باشد، filter-branch می‌تواند به شما کمک کند:

$ git filter-branch --subdirectory-filter trunk HEAD
Rewrite 856f0bf61e41a27326cdae8f09fe708d679f596f (12/12)
Ref 'refs/heads/master' was rewritten

حالا ریشه جدید پروژه شما همان چیزی است که در هر بار زیرشاخه trunk بوده است. گیت همچنین به طور خودکار کامیت‌هایی را که روی آن زیرشاخه تأثیر نداشته‌اند حذف می‌کند.

تغییر آدرس ایمیل به صورت سراسری (Changing Email Addresses Globally)

یک مورد رایج دیگر این است که فراموش کرده‌اید قبل از شروع کار، git config را برای تنظیم نام و ایمیل خود اجرا کنید، یا شاید بخواهید پروژه‌ای که در محل کار دارید را متن‌باز کنید و همه آدرس‌های ایمیل کاری را به آدرس شخصی خود تغییر دهید. در هر صورت، می‌توانید آدرس‌های ایمیل را در چندین کامیت به صورت دسته‌ای با filter-branch تغییر دهید. باید مراقب باشید فقط آدرس‌های ایمیل خود را تغییر دهید، بنابراین از گزینه --commit-filter استفاده می‌کنید:

$ git filter-branch --commit-filter '
        if [ "$GIT_AUTHOR_EMAIL" = "schacon@localhost" ];
        then
                GIT_AUTHOR_NAME="Scott Chacon";
                GIT_AUTHOR_EMAIL="schacon@example.com";
                git commit-tree "$@";
        else
                git commit-tree "$@";
        fi' HEAD

این دستور هر کامیت را بازنویسی می‌کند تا آدرس ایمیل جدید شما داشته باشد. چون کامیت‌ها شامل مقادیر SHA-1 والدهای خود هستند، این دستور SHA-1 همه کامیت‌های تاریخچه شما را تغییر می‌دهد، نه فقط آن‌هایی که آدرس ایمیلشان مطابقت دارد.

scroll-to-top