পরিবর্তন পূর্বাবস্থায় ফেরানো — checkout, restore, reset, revert
এই পাঠে যা শিখবেন
- চারটি ভিন্ন "আনডু" অপারেশন এবং প্রতিটি ঠিক কোন ট্রি-তে কাজ করে
git reset-এর তিনটি মোড এবং প্রতিটির প্রকৃত, ভিন্ন ফলাফলgit revertকীভাবে history অক্ষত রেখে একটি কমিটের প্রভাব বাতিল করে- কোড দিয়ে যাচাই — soft/mixed/hard reset-এর ভিন্নতা এবং revert বনাম hard reset-এর history-প্রভাব
১ · চারটি ভিন্ন "আনডু" — কেন এত বিভ্রান্তি হয়
Git-এ "আনডু" করার একাধিক উপায় থাকা একটি সাধারণ বিভ্রান্তির উৎস — কারণ প্রতিটি কমান্ড আলাদা স্কোপে এবং আলাদা নিরাপত্তা-স্তরে কাজ করে। L30-এর থ্রি-ট্রি মডেল (Working Directory, Staging Area, Repository) মনে রাখলে এই বিভ্রান্তি দূর হয় — প্রতিটি আনডু কমান্ড ঠিক বলা যায় কোন ট্রি(গুলো) স্পর্শ করে। এই পাঠে আমরা চারটি কমান্ডকেই তাদের স্কোপ অনুযায়ী সাজিয়ে দেখব।
২ · git restore — Working Directory ও Staging-এর ছোট আনডু
git restoregit restoreএকটি ফাইলের Working Directory-র পরিবর্তন বাতিল করে (last commit/staged ভার্সনে ফিরিয়ে আনে), অথবা --staged ফ্ল্যাগ দিয়ে শুধু staging থেকে বের করে আনে।
<file> শুধু Working Directory-তে থাকা unstaged পরিবর্তন বাতিল করে —
ফাইলটি শেষ কমিট (বা শেষ staged ভার্সন)-এ ফিরিয়ে আনে। git restore --staged <file>
ফাইলটিকে Staging Area থেকে বের করে আনে, কিন্তু Working Directory-র এডিট মুছে দেয় না — একটি নিরাপদ
"উফ, এখনই এটা স্টেজ করতে চাইনি" অপারেশন।
৩ · git reset — branch পয়েন্টার সরানো, তিনটি মোডে
git resetgit resetবর্তমান branch পয়েন্টারকে একটি আগের কমিটে সরিয়ে নেয় -- --soft/--mixed/--hard মোড অনুযায়ী Staging Area ও Working Directory-তে ভিন্ন প্রভাব ফেলে।
<commit> বর্তমান branch পয়েন্টারকে একটি আগের কমিটে সরিয়ে নেয়। কতটা
"পেছনে সরে" তা নির্ভর করে মোডের ওপর:
শুধু branch পয়েন্টার সরে। Staging Area ও Working Directory স্পর্শই করা হয় না — তাই "undone" কমিটগুলোর পরিবর্তন এখন নতুন HEAD-এর তুলনায় staged হিসেবে দেখা যায়, নতুন করে কমিট করার জন্য প্রস্তুত।
branch পয়েন্টার সরে, Staging Area টার্গেট কমিটের সাথে মিলিয়ে রিসেট হয় — কিন্তু Working Directory অপরিবর্তিত থাকে। ফলে পরিবর্তনগুলো unstaged অবস্থায় দেখা যায়।
branch পয়েন্টার সরে, Staging Area ও Working Directory দুটোই টার্গেট কমিটের সাথে মিলিয়ে রিসেট হয় — আনকমিটেড কাজসহ সবকিছু স্থায়ীভাবে হারিয়ে যায়।
৪ · git revert — নিরাপদ, শেয়ার্ড history-র জন্য
git revertgit revertএকটি নির্দিষ্ট পুরনো কমিটের পরিবর্তন বাতিল করতে একটি নতুন কমিট তৈরি করে -- পুরনো কমিটটি history থেকে মুছে ফেলে না।
<commit> একটি নতুন কমিট তৈরি করে যার পরিবর্তন টার্গেট কমিটের পরিবর্তনকে
উল্টে দেয় — কিন্তু টার্গেট কমিটটি নিজে history-তে অক্ষত থেকে যায়। এটিই কেন revert শেয়ার্ড
(অন্য কারো সাথে পুশ করা) history-র জন্য নিরাপদ পছন্দ — এটি বিদ্যমান কোনো কমিট পুনর্লিখন/মুছে দেয় না
(M8/L38-এ rebase-এর সাথে এই নিরাপত্তা-প্রশ্নটি আরও গভীরভাবে আসবে), যেখানে --hard reset
সেই কমিটগুলোকে reachable history থেকে সরিয়ে দেয়।
--hard reset মানেই ডেটা "মুছে ফেলা" নয়, বরং সেই কমিটগুলোকে unreachable
করে দেওয়া — কমিট অবজেক্টগুলো সাথে সাথে ডিলিট হয় না (বাস্তব git-এ এগুলো পরে garbage-collect হওয়ার আগ
পর্যন্ত টেকনিক্যালি টিকে থাকে), কিন্তু কোনো branch পয়েন্টার আর সেগুলোতে পৌঁছাতে পারে না — তাই
git log-এ আর দেখা যায় না, এবং ব্যবহারিকভাবে হারিয়ে যাওয়ারই সমতুল্য।
৫ · কোড দিয়ে যাচাই
নিচের কোড সেলটি বাস্তব git বাইনারি চালায় না — এই ব্রাউজার-স্যান্ডবক্সে কোনো real git
ইনস্টল নেই — বরং এটি L30-এর Repo মডেল সম্প্রসারণ করে git_reset-এর তিনটি মোড এবং
git_revert-এর আচরণ real git-এর অ্যালগরিদম যেভাবে কাজ করে ঠিক সেভাবেই বিশ্বস্তভাবে
সিমুলেট করছে। প্রথমে একই ৩-কমিট repo-র copy.deepcopy তিনবার নিয়ে তিনটি ভিন্ন মোডে reset
করে Staging Area/Working Directory তুলনা করা হচ্ছে, তারপর একই chain-এর ওপর git_revert
বনাম --hard reset-এর history-প্রভাব তুলনা করা হচ্ছে।
import copy
# git-এর থ্রি-ট্রি মডেল (L30 রিইউজ, হুবহু একই) -- এই স্যান্ডবক্সে real git নেই,
# তাই এটি git-এর অভ্যন্তরীণ আচরণের একটি বিশ্বস্ত সিমুলেশন, real git চালানো নয়।
def make_repo():
return {"working_directory": {}, "staging_area": {}, "commits": {}, "head": None}
def git_add(repo, filename):
repo["staging_area"][filename] = repo["working_directory"][filename]
def git_commit(repo, message):
commit_hash = f"c{len(repo['commits']) + 1}"
snapshot = dict(repo["staging_area"])
repo["commits"][commit_hash] = {"message": message, "snapshot": snapshot, "parent": repo["head"]}
repo["head"] = commit_hash
repo["staging_area"] = {} # বাস্তব git-এর মতোই কমিটের পর staging area খালি হয়ে যায় (L30 রিইউজ)
return commit_hash
def git_log(repo):
"""HEAD থেকে parent অনুসরণ করে পেছনের দিকে -- শুধু reachable কমিটগুলোই দেখায়।"""
hashes = []
current = repo["head"]
while current:
hashes.append(current)
current = repo["commits"][current]["parent"]
return hashes
# ---- git_reset: তিনটি মোড, তিনটি ভিন্ন ফলাফল ----
def git_reset(repo, target_hash, mode="mixed"):
old_snapshot = dict(repo["commits"][repo["head"]]["snapshot"])
target_snapshot = dict(repo["commits"][target_hash]["snapshot"])
if mode == "soft":
# branch পয়েন্টার সরে, কিন্তু "undone" কমিটের পরিবর্তন staged অবস্থায় রেখে দেওয়া হয় (রিকমিট করার জন্য প্রস্তুত)
repo["staging_area"] = old_snapshot
# working_directory অপরিবর্তিত থাকে
elif mode == "mixed":
repo["staging_area"] = dict(target_snapshot) # working_directory অপরিবর্তিত
elif mode == "hard":
repo["staging_area"] = dict(target_snapshot)
repo["working_directory"] = dict(target_snapshot)
else:
raise ValueError("mode must be 'soft', 'mixed', or 'hard'")
repo["head"] = target_hash
return repo
# ---- git_revert: নতুন কমিট, পুরনোটা history-তে অক্ষত ----
def git_revert(repo, target_hash):
target_commit = repo["commits"][target_hash]
parent_hash = target_commit["parent"]
reverted_snapshot = dict(repo["commits"][parent_hash]["snapshot"]) if parent_hash else {}
new_hash = f"c{len(repo['commits']) + 1}"
repo["commits"][new_hash] = {
"message": f'Revert "{target_commit["message"]}"',
"snapshot": reverted_snapshot,
"parent": repo["head"],
}
repo["head"] = new_hash
repo["staging_area"] = dict(reverted_snapshot)
repo["working_directory"] = dict(reverted_snapshot)
return new_hash
def build_demo_repo():
repo = make_repo()
repo["working_directory"]["notes.txt"] = "প্রথম লাইন"
git_add(repo, "notes.txt")
git_commit(repo, "c1: প্রথম লাইন")
repo["working_directory"]["notes.txt"] = "প্রথম লাইন\nদ্বিতীয় লাইন"
git_add(repo, "notes.txt")
git_commit(repo, "c2: দ্বিতীয় লাইন যোগ")
repo["working_directory"]["notes.txt"] = "প্রথম লাইন\nদ্বিতীয় লাইন\nভুল লাইন"
git_add(repo, "notes.txt")
git_commit(repo, "c3: ভুল লাইন যোগ (ভুল কমিট)")
return repo
base = build_demo_repo()
# ---- soft vs mixed vs hard -- একই টার্গেট (c1), স্বতন্ত্র কপিতে ----
repo_soft = copy.deepcopy(base)
git_reset(repo_soft, "c1", mode="soft")
repo_mixed = copy.deepcopy(base)
git_reset(repo_mixed, "c1", mode="mixed")
repo_hard = copy.deepcopy(base)
git_reset(repo_hard, "c1", mode="hard")
print("=== --soft reset (টার্গেট c1) ===")
print("staging_area: ", repo_soft["staging_area"])
print("working_directory: ", repo_soft["working_directory"])
print("\n=== --mixed reset (টার্গেট c1) ===")
print("staging_area: ", repo_mixed["staging_area"])
print("working_directory: ", repo_mixed["working_directory"])
print("\n=== --hard reset (টার্গেট c1) ===")
print("staging_area: ", repo_hard["staging_area"])
print("working_directory: ", repo_hard["working_directory"])
# ---- revert বনাম hard reset -- কমিট হিস্ট্রির ওপর প্রভাব ----
repo_revert = copy.deepcopy(base)
git_revert(repo_revert, "c3")
repo_hard2 = copy.deepcopy(base)
git_reset(repo_hard2, "c2", mode="hard")
print("\n=== git_revert(c3)-এর পর git log (HEAD থেকে reachable) ===")
print(git_log(repo_revert))
print("reachable কমিট সংখ্যা:", len(git_log(repo_revert)))
print("\n=== --hard reset(লক্ষ্য c2)-এর পর git log (HEAD থেকে reachable) ===")
print(git_log(repo_hard2))
print("reachable কমিট সংখ্যা:", len(git_log(repo_hard2)))
print("(repo['commits']-এ এখনও c3 অবজেক্ট হিসেবে আছে, মোট এন্ট্রি:", len(repo_hard2["commits"]),
"-- শুধু কোনো branch pointer আর সেটাতে পৌঁছাতে পারে না)")
--soft-এ staging ও
working দুটোই এখনও c3-এর তিন-লাইন কন্টেন্ট ধরে রাখে (রিকমিট করার জন্য প্রস্তুত), --mixed-এ
staging c1-এর এক-লাইন কন্টেন্টে রিসেট হয় কিন্তু working তিন-লাইনই থেকে যায় (unstaged পরিবর্তন হিসেবে
দেখা যাবে), আর --hard-এ দুটোই c1-এর এক-লাইন কন্টেন্টে চলে যায় (কিছুই বাকি থাকে না)। নিচে
git_revert(c3)-এর পর log-এ ৪টি কমিট (c4,c3,c2,c1) দেখা যায় — c3 এখনও আছে — কিন্তু
--hard reset(c2)-এর পর log-এ মাত্র ২টি (c2,c1) — c3 হারিয়ে গেছে, যদিও
repo["commits"] ডিকশনারিতে তার অবজেক্ট টেকনিক্যালি এখনও আছে।
চারটি আনডু কমান্ডকে স্কোপ অনুযায়ী মনে রাখুন: restore = শুধু Working Directory,
restore --staged = শুধু un-stage, reset = branch পয়েন্টার সরানো (মোড
অনুযায়ী আরও বেশি স্পর্শ করে), revert = history অক্ষত রেখে সামনে এগিয়ে নতুন কমিট। যত বেশি
"ধ্বংসাত্মক" হওয়ার সম্ভাবনা, তত বেশি সতর্কতা দরকার — বিশেষ করে --hard reset আর
already-shared history-তে reset ব্যবহারের ক্ষেত্রে।
ভাবনার প্রশ্ন
প্রতিটি প্রশ্ন নিজে কিছুক্ষণ ভাবুন — তারপর "→ উত্তর" চাপুন।
প্র ০১
--soft reset-এর পর staging_area-তে থাকা ডেটা আসলে কী represent করে, এবং কেন সেটা "রিকমিট করার জন্য প্রস্তুত" বলা হয়?
--soft reset শুধু branch পয়েন্টার সরায়, staging_area একদমই স্পর্শ করে না — তাই সেটি
আগের মতোই থেকে যায়, অর্থাৎ পুরনো (এখন "undone") HEAD-এর কন্টেন্ট। যেহেতু branch পয়েন্টার এখন পেছনের
একটি কমিটে আছে, staging_area-র এই কন্টেন্ট নতুন HEAD-এর তুলনায় "staged পরিবর্তন" হিসেবে দেখা যায় —
অর্থাৎ পরের git commit কল করলেই সেই কন্টেন্ট (হয়তো নতুন, ভালো একটি মেসেজ দিয়ে) আবার
কমিট হয়ে যাবে।
প্র ০২
--hard reset কেন এত বিপজ্জনক — বাস্তব জীবনে কোন পরিস্থিতিতে এটি সত্যিকারের ডেটা হারানোর কারণ হতে পারে?
--hard reset Working Directory-কেও টার্গেট কমিটের snapshot দিয়ে ওভাররাইট করে দেয় —
অর্থাৎ যদি Working Directory-তে এমন কোনো পরিবর্তন থাকে যা কখনো কমিট করা হয়নি (তাই কোনো
commit snapshot-এ নেই), সেই পরিবর্তন স্থায়ীভাবে হারিয়ে যায়, কোনো ব্যাকআপ ছাড়াই। বাস্তবে এটি ঘটে যখন
কেউ কয়েক ঘণ্টার আনকমিটেড কাজ চলাকালীন ভুলবশত reset --hard চালিয়ে ফেলে, ভেবেছিল সেটা শুধু
কমিট হিস্ট্রি পরিষ্কার করবে।
প্র ০৩
revert আর --hard reset উভয়ই "একটা কমিটের প্রভাব বাতিল" করে মনে হতে পারে — শেয়ার্ড/পুশ করা history-র ক্ষেত্রে আসল পার্থক্যটা কী?
--hard reset বিদ্যমান কমিটগুলোকে unreachable করে দেয় — যদি সেই কমিটগুলো ইতিমধ্যে অন্য কারো
সাথে শেয়ার (পুশ) করা হয়ে থাকে, তাদের কপিতে সেগুলো এখনও থাকবে, ফলে দুই পক্ষের history আলাদা হয়ে যায় ও
পরে সিঙ্ক করতে গিয়ে জটিলতা তৈরি হয়। revert কোনো বিদ্যমান কমিট সরায় না — শুধু সামনে একটি
নতুন কমিট যোগ করে যা আগেরটির প্রভাব বাতিল করে — তাই যে কেউ history পুল করলে ঠিক সেই একই, সামঞ্জস্যপূর্ণ
ছবি পায়, কোনো conflict বা বিভ্রান্তি ছাড়াই।
অনুশীলন
-
পরীক্ষা করুন: কোড সেলে
git_reset(repo_mixed, "c1", mode="mixed")-এর বদলে টার্গেট"c2"-তে reset করে দেখুন — staging_area ও working_directory-র ফলাফল কীভাবে বদলায়?টার্গেট c2 হলে
staging_areaএখন c2-এর snapshot ধরবে (দুই-লাইন কন্টেন্ট), আরworking_directoryআগের মতোই তিন-লাইন কন্টেন্ট (base repo-র শেষ অবস্থা) ধরে রাখবে — অর্থাৎ working_directory-র তৃতীয় লাইনটি এখন staging-এর তুলনায় "modified (not staged)" হিসেবে দেখা যাবে, কিন্তু c3 কমিটের পুরো পরিবর্তন (দুই-লাইন থেকে তিন-লাইনে যাওয়া) আর staged নেই। -
চিন্তা করুন:
git_revert(repo, "c2")কল করলে (c3-এর বদলে c2-তে) নতুন কমিটের snapshot কী হবে, এবং কেন c3-এর পরিবর্তনও (আংশিকভাবে) সেই নতুন snapshot-এ প্রভাব ফেলবে?আমাদের সরলীকৃত
git_revertশুধু টার্গেট কমিটেরparent-এর snapshot ফিরিয়ে আনে — c2-এর parent হলো c1, তাই নতুন কমিটের snapshot হবে c1-এর এক-লাইন কন্টেন্ট। কিন্তু বর্তমান HEAD তখনও c3 (তিন-লাইন), তাই এই revert শুধু c2-এর পরিবর্তনই নয়, c3-এর পরিবর্তনও কার্যত মুছে দেবে — বাস্তবgit revert-এ এই পরিস্থিতিতে (মাঝের একটি কমিট revert করা, যেখানে পরে আরও কমিট আছে) সাধারণত conflict দেখা দেয়, কারণ c3-এর পরিবর্তন c2-এর ওপর নির্ভরশীল হতে পারে — এটাই কেন বাস্তবে সাধারণত সবচেয়ে সাম্প্রতিক কমিট revert করাই সবচেয়ে নিরাপদ ও পরিষ্কার।
আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ
- কোর্সের সম্পূর্ণ সিলেবাস দেখুন ৫৮টি পাঠ পরবর্তী পাঠ — Git ব্রাঞ্চিং বেসিকস — M8-এর শুরু, খুব শীঘ্রই।
- L30 · git init, add, commit — থ্রি-ট্রি আর্কিটেকচার পূর্বশর্ত Working Directory / Staging Area / Repository — এই তিনটি ট্রি-র মডেল ছাড়া আজকের চারটি আনডু কমান্ড বোঝা কঠিন।
- L38 · Merge বনাম Rebase — কখন কোনটা ব্যবহার করবেন সম্পর্কিত পাঠ আজকের "revert বনাম reset, শেয়ার্ড history-তে নিরাপত্তা" থিমটাই সেখানে rebase-এর প্রসঙ্গে আরও গভীরভাবে ফিরে আসবে।