Git Rebase — ধারণা ও ইন্টারঅ্যাক্টিভ রিবেস
এই পাঠে যা শিখবেন
- Rebase মার্জ থেকে কীভাবে আলাদা, এবং কেন এটি একটি লিনিয়ার হিস্ট্রি তৈরি করে
- রিবেসের সময় কমিট হ্যাশ কেন বদলে যায় অথচ কনটেন্ট অপরিবর্তিত থাকে
- রিবেসের সোনালী নিয়ম — কখন rebase নিরাপদ, কখন বিপজ্জনক
- ইন্টারঅ্যাক্টিভ রিবেস দিয়ে কীভাবে messy commit history স্কোয়াশ করে পরিষ্কার করা হয়
- Python দিয়ে
git_rebaseও commit-squash লজিক সিমুলেট ও যাচাই করা
১ · Rebase কী এবং কেন দরকার
M8/L35-এ আমরা দেখেছি mergeMergeদুটো ব্রাঞ্চের ইতিহাস একসাথে করে একটি নতুন two-parent commit তৈরি করা। কীভাবে দুটো ডাইভার্জড ব্রাঞ্চকে একটি two-parent merge commit দিয়ে একসাথে করে। RebaseRebaseএকটি ব্রাঞ্চের নিজস্ব commit গুলো অন্য একটি ব্রাঞ্চের শেষের ওপর একে একে "রিপ্লে" করে ইন্টিগ্রেট করার পদ্ধতি — কোনো merge commit ছাড়াই। একই কাজ — এক ব্রাঞ্চের পরিবর্তন আরেক ব্রাঞ্চে নিয়ে আসা — সম্পূর্ণ ভিন্নভাবে করে। merge commit তৈরি করার বদলে, rebase বর্তমান ব্রাঞ্চের নিজস্ব commit গুলোকে টার্গেট ব্রাঞ্চের বর্তমান শেষ (tip) কমিটের ওপর একটার পর একটা "রিপ্লে" করে বসিয়ে দেয়। ফলাফল: কোনো merge commit ছাড়াই একটি সম্পূর্ণ লিনিয়ার (সরলরৈখিক) কমিট হিস্ট্রি — যেন branch-টা আসলে সবসময় লেটেস্ট main-এর ওপর থেকেই শুরু হয়েছিল, এমন দেখায়।
২ · Rebase আসলে কীভাবে কাজ করে — replay ও নতুন হ্যাশ
একটা গুরুত্বপূর্ণ, প্রায়ই ভুল বোঝা টেকনিক্যাল বিস্তারিত: rebase আসলে মূল commit গুলোকে "সরায়" না। বরং এটি
প্রতিটি commit-এর জন্য একদম ব্র্যান্ড-নতুন commit তৈরি করে — একই মেসেজ, একই কনটেন্ট
(স্ন্যাপশট), কিন্তু ভিন্ন parent। যেহেতু একটি commit-এর হ্যাশ তার কনটেন্ট (parent রেফারেন্সসহ)
থেকে নির্ধারিত হয়, parent বদলালে হ্যাশও বদলে যায়। মূল commit গুলো সাথে সাথে মুছে যায় না — সেগুলো
"অরফান" (কোনো ব্রাঞ্চ থেকে আর পৌঁছানো যায় না) হয়ে পড়ে, এবং পরে git গার্বেজ-কালেক্ট করে।
যে commit ইতিমধ্যে পুশ/শেয়ার করা হয়ে গেছে, তা কখনো rebase করবেন না। যেহেতু rebase নতুন commit হ্যাশ তৈরি করে, যাদের কাছে ইতিমধ্যে পুরনো হ্যাশগুলো আছে (কলিগ, রিমোট রিপোজিটরি) তাদের হিস্ট্রি আপনার নতুন হিস্ট্রির সাথে ডাইভার্জ করে যাবে — একই পরিবর্তনের দুটো ভিন্ন-হ্যাশের ভার্সন একসাথে থাকবে, যা মারাত্মক কোলাবোরেশন সমস্যা তৈরি করে (M9/L38 এ এই সিদ্ধান্তটি আরও বিস্তারিত দেখব)।
৩ · ইন্টারঅ্যাক্টিভ রিবেস — history পরিষ্কার করা
git rebase -i (ইন্টারঅ্যাক্টিভ রিবেস) rebase-এর একটি ব্যবহারিকভাবে খুবই দরকারি ফিচার — এটি
প্রতিটি রিপ্লে হতে যাওয়া commit-এর জন্য একটি অ্যাকশন বেছে নেওয়ার সুযোগ দেয়, শেয়ার করার আগে
লোকাল হিস্ট্রি পরিষ্কার করার জন্য (সোনালী নিয়মের সাথে সামঞ্জস্যপূর্ণ — এই পরিষ্কার করাটা কেবল
not-yet-shared commit-এই করা উচিত)।
commit গুলোর ক্রম বদলানো।
একটি নির্দিষ্ট commit-এর কনটেন্ট/মেসেজ পরিবর্তন করা।
একাধিক commit একটিতে মিলিয়ে ফেলা — একটি পরিষ্কার, রিভিউযোগ্য commit-এ পরিণত করা।
একটি commit সম্পূর্ণ বাদ দেওয়া।
নিচের কোড সেলে আমরা git_rebase ও একটি সরলীকৃত squash ফাংশন সিমুলেট করছি। এটি
git-এর প্রকৃত অভ্যন্তরীণ অ্যালগরিদমের একটি নির্ভুল Python সিমুলেশন — বাস্তব git বাইনারি এই
ব্রাউজার-স্যান্ডবক্সে নেই, তাই কোনো real subprocess/git কল হচ্ছে না। আমরা M7/L30 ও M8/L34-এ
প্রতিষ্ঠিত ঠিক একই Repo মডেল (working_directory, staging_area,
commits, branches, head) পুনর্ব্যবহার করছি।
# git rebase সিমুলেশন -- L30/L34-এর ঠিক একই Repo মডেল ব্যবহার করে।
# বাস্তব git বাইনারি এই স্যান্ডবক্সে নেই, তাই এটি git-এর নিজস্ব রিবেস অ্যালগরিদমের
# (merge base খোঁজা -> commit replay করা -> branch pointer সরানো) একটি from-scratch, নির্ভুল সিমুলেশন।
def make_repo():
return {
"working_directory": {},
"staging_area": {},
"commits": {},
"branches": {"main": None},
"head": "main",
}
def _new_hash(repo):
return f"c{len(repo['commits']) + 1}"
def current_commit(repo):
if repo["head"] in repo["branches"]:
return repo["branches"][repo["head"]]
return repo["head"]
def git_add(repo, filename):
repo["staging_area"][filename] = repo["working_directory"][filename]
def git_commit(repo, message):
parent = current_commit(repo)
commit_hash = _new_hash(repo)
repo["commits"][commit_hash] = {
"message": message,
"snapshot": dict(repo["staging_area"]),
"parent": parent,
}
if repo["head"] in repo["branches"]:
repo["branches"][repo["head"]] = commit_hash
else:
repo["head"] = commit_hash
repo["staging_area"] = {}
return commit_hash
def git_branch(repo, name):
repo["branches"][name] = current_commit(repo)
def git_switch(repo, name):
repo["head"] = name
def find_merge_base(repo, tip_a, tip_b):
ancestors_a = set()
current = tip_a
while current is not None:
ancestors_a.add(current)
current = repo["commits"][current]["parent"]
current = tip_b
while current is not None:
if current in ancestors_a:
return current
current = repo["commits"][current]["parent"]
return None
def git_rebase(repo, branch, onto_branch):
branch_tip = repo["branches"][branch]
onto_tip = repo["branches"][onto_branch]
base = find_merge_base(repo, branch_tip, onto_tip)
# branch-এর নিজস্ব commit গুলো (base-এর পর থেকে), পুরনো -> নতুন ক্রমে
unique = []
current = branch_tip
while current != base:
unique.append(current)
current = repo["commits"][current]["parent"]
unique.reverse()
hash_map = {}
new_parent = onto_tip
for old_hash in unique:
old = repo["commits"][old_hash]
new_hash = _new_hash(repo)
repo["commits"][new_hash] = {
"message": old["message"],
"snapshot": dict(old["snapshot"]),
"parent": new_parent,
}
hash_map[old_hash] = new_hash
new_parent = new_hash
repo["branches"][branch] = new_parent
return hash_map
repo = make_repo()
repo["working_directory"]["app.py"] = "print('v1')"
git_add(repo, "app.py")
git_commit(repo, "প্রজেক্ট শুরু")
git_branch(repo, "feature-x")
git_switch(repo, "feature-x")
repo["working_directory"]["login.py"] = "def login(): pass"
git_add(repo, "login.py")
git_commit(repo, "লগইন ফাংশন যোগ")
repo["working_directory"]["login.py"] = "def login(): return True"
git_add(repo, "login.py")
git_commit(repo, "লগইন রিটার্ন ভ্যালু ঠিক করা")
git_switch(repo, "main")
repo["working_directory"]["readme.md"] = "# Project"
git_add(repo, "readme.md")
git_commit(repo, "README যোগ করা হলো")
print("Rebase-এর আগে:")
print(" main ->", repo["branches"]["main"])
print(" feature-x ->", repo["branches"]["feature-x"])
hash_map = git_rebase(repo, "feature-x", "main")
print("\nRebase-এর পরে:")
print(" main ->", repo["branches"]["main"])
print(" feature-x ->", repo["branches"]["feature-x"])
print("\nমূল commit হ্যাশ -> নতুন replay করা commit হ্যাশ (মেসেজ অপরিবর্তিত থাকছে):")
for old_hash, new_hash in hash_map.items():
msg = repo["commits"][new_hash]["message"]
print(f" {old_hash} -> {new_hash} \"{msg}\"")
hashes_differ = all(old != new for old, new in hash_map.items())
print("\nসব রিপ্লে করা commit-এর হ্যাশ মূল হ্যাশ থেকে ভিন্ন?", hashes_differ)
def interactive_rebase_squash(commits_list, squash_indices):
squash_set = set(squash_indices)
if not squash_set:
return list(commits_list)
start = min(squash_set)
end = max(squash_set)
kept_before = commits_list[:start]
kept_after = commits_list[end + 1:]
to_squash = commits_list[start:end + 1]
combined_message = "; ".join(c["message"] for c in to_squash)
combined_snapshot = {}
for c in to_squash:
combined_snapshot.update(c["snapshot"]) # পরের commit-এর কনটেন্ট শেষ অবস্থাকে override করে
squashed_commit = {"message": combined_message, "snapshot": combined_snapshot}
return kept_before + [squashed_commit] + kept_after
messy_history = [
{"message": "WIP: ফিচার শুরু করা হলো", "snapshot": {"cart.py": "v1"}},
{"message": "WIP: টেস্ট ঠিক করা হলো", "snapshot": {"cart.py": "v2"}},
{"message": "WIP: টাইপো ফিক্স", "snapshot": {"cart.py": "v3", "cart_test.py": "v1"}},
]
print("\n\nSquash-এর আগে: মোট", len(messy_history), "টি commit")
for c in messy_history:
print(" -", c["message"])
cleaned_history = interactive_rebase_squash(messy_history, [0, 1, 2])
print("\nSquash-এর পরে: মোট", len(cleaned_history), "টি commit")
for c in cleaned_history:
print(" -", c["message"])
print(" ফাইল অবস্থা:", c["snapshot"])
hash_map-এ মূল হ্যাশ (যেমন feature-x-এর নিজস্ব দুটো commit) আর রিপ্লে-করা নতুন
হ্যাশ (main-এর tip-এর ওপর বসানো) সম্পূর্ণ ভিন্ন — কিন্তু প্রতিটি নতুন commit-এর message
এবং snapshot মূল commit-এর সাথে হুবহু মিলে যায়। এটিই "কনটেন্ট সংরক্ষিত থাকে, হ্যাশ বদলে যায়"
নীতির সরাসরি, কোড-যাচাইকৃত প্রমাণ। squash উদাহরণে ৩টি এলোমেলো WIP commit একটি পরিষ্কার commit-এ পরিণত
হয়েছে, অথচ চূড়ান্ত ফাইল অবস্থা (cart.py-এর শেষ ভার্সন এবং cart_test.py) ঠিক
একই আছে — কোনো পরিবর্তন হারায়নি।
Rebase merge-এর একটি বিকল্প ইন্টিগ্রেশন কৌশল — merge commit-এর বদলে commit replay করে একটি লিনিয়ার হিস্ট্রি দেয়, এবং ইন্টারঅ্যাক্টিভ রিবেস দিয়ে শেয়ার করার আগে সেই হিস্ট্রি পরিষ্কার করা যায়। কিন্তু এই ক্ষমতার সাথে একটি কঠোর দায়িত্বও আসে — সোনালী নিয়ম: শুধু লোকাল, শেয়ার-না-করা commit-এই rebase করুন। পরের পাঠে (L38) আমরা দেখব কখন merge আর কখন rebase বেছে নেওয়া উচিত।
ভাবনার প্রশ্ন
প্রতিটি প্রশ্ন নিজে কিছুক্ষণ ভাবুন — তারপর "→ উত্তর" চাপুন।
প্র ০১ "Rebase-এর সময় নতুন commit হ্যাশ তৈরি হয়" — এর মানে কি মূল commit গুলো সাথে সাথে মুছে যায়?
না। মূল commit গুলো তাৎক্ষণিকভাবে মুছে যায় না — সেগুলো কেবল কোনো ব্রাঞ্চ পয়েন্টার থেকে আর "পৌঁছানো যায় না" (অরফান হয়ে যায়), কারণ ব্রাঞ্চের পয়েন্টার এখন নতুন, রিপ্লে-করা commit-গুলোর দিকে নির্দেশ করছে। Git পরবর্তীতে (সাধারণত পর্যায়ক্রমে) এই অরফান commit গুলো গার্বেজ-কালেক্ট করে মুছে ফেলে। কিন্তু যতক্ষণ পর্যন্ত সেটা না হয়, প্রযুক্তিগতভাবে সেগুলো এখনও রিপোজিটরিতে বিদ্যমান থাকে।
প্র ০২ কেন rebase-এর "সোনালী নিয়ম" বলে যে শেয়ার-করা commit rebase করা উচিত না?
কারণ rebase নতুন commit হ্যাশ তৈরি করে (একই কনটেন্ট, ভিন্ন parent)। যদি অন্য কেউ ইতিমধ্যে পুরনো হ্যাশগুলো তাদের নিজেদের লোকাল রিপোতে নিয়ে থাকে, তাহলে rebase-এর পর আপনার হিস্ট্রি এবং তাদের হিস্ট্রি সম্পূর্ণ ভিন্ন commit হ্যাশ নিয়ে ডাইভার্জ করবে — একই মূল পরিবর্তনের দুটো ভিন্ন কপি থাকবে। পরের বার push/pull করার সময় এটি বিভ্রান্তিকর কনফ্লিক্ট ও ডুপ্লিকেট commit তৈরি করতে পারে।
প্র ০৩
কোড সেলে interactive_rebase_squash-এ combined_snapshot তৈরির সময় প্রতিটি commit-এর snapshot দিয়ে ক্রমান্বয়ে dict.update() করা হয়েছে কেন?
কারণ আমরা চাই squash-এর ফলাফল ফাইলগুলোর সর্বশেষ (চূড়ান্ত) অবস্থা প্রতিফলিত করুক — ঠিক
যেমন বাস্তবে আলাদা আলাদা commit না থাকলেও ফাইলগুলো একই চূড়ান্ত অবস্থায় থাকত। যেহেতু
to_squash লিস্টটি পুরনো থেকে নতুন ক্রমে সাজানো, পরের commit-এর update()
কল আগের commit-এর একই ফাইলের কনটেন্ট override করে দেয় — ফলে cart.py-এর জন্য শুধু "v3"
(সর্বশেষ ভার্সন) টিকে থাকে, "v1"/"v2" নয়। যদি ক্রম উল্টো (নতুন থেকে পুরনো) করা হতো, তাহলে পুরনো,
স্টেল কনটেন্ট নতুনটাকে ভুলভাবে override করে ফেলত — যা চূড়ান্ত অবস্থা ভুল করে দিত।
অনুশীলন
-
চিন্তা করুন: দুইজন ডেভেলপার একই রিমোট থেকে
feature-yব্রাঞ্চ ক্লোন করেছে। একজন নিজের কপিতে rebase করে ইতিমধ্যে-পুশ-করা কিছু commit-এর হিস্ট্রি বদলে আবার পুশ করে ফেলে। অন্যজন এরপর সাধারণgit pullকরলে কী ধরনের সমস্যা দেখা দিতে পারে বলে মনে হয়?দ্বিতীয় ডেভেলপারের লোকাল কপিতে এখনও পুরনো-হ্যাশের commit গুলো আছে, আর রিমোটে এখন নতুন-হ্যাশের রিপ্লে-করা commit গুলো আছে — এই দুই হিস্ট্রি ডাইভার্জড দেখাবে, যদিও কনটেন্ট আসলে একই। সাধারণ
git pullএখানে একটি বিশৃঙ্খল merge/কনফ্লিক্ট তৈরি করতে পারে, অথবা একই পরিবর্তনের দুটো কপি (পুরনো + নতুন হ্যাশ, দুটোই) হিস্ট্রিতে থেকে যেতে পারে — ঠিক যা সোনালী নিয়ম প্রতিরোধ করতে চায়। -
পরীক্ষা করুন: উপরের কোড সেলে
feature-x-এ rebase করার আগে আরেকটি তৃতীয় commit (আপনার নিজের মেসেজ দিয়ে) যোগ করুন, তারপর Run চেপে দেখুনhash_map-এ এখন কয়টি এন্ট্রি আসে।এখন
hash_map-এ ৩টি এন্ট্রি থাকবে (মূল ৩টি feature-x commit, প্রতিটির জন্য একটি নতুন রিপ্লে-করা হ্যাশ) — কারণgit_rebasemerge base থেকে branch-এর tip পর্যন্ত সব commit-ই একে একে খুঁজে বের করে রিপ্লে করে, সংখ্যা যতই হোক না কেন। বাকি লজিক (মেসেজ/কনটেন্ট সংরক্ষণ, ভিন্ন হ্যাশ) অপরিবর্তিত থাকে।
আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ
- কোর্সের সম্পূর্ণ সিলেবাস দেখুন ৫৮টি পাঠ পরবর্তী পাঠ — মার্জ বনাম রিবেস, কখন কোনটা ব্যবহার করবেন।
- Data Structures & Algorithms কোর্স সহোদর কোর্স merge base খোঁজার জন্য ব্যবহৃত ancestor-chain ওয়াক আসলে একটি গ্রাফ/ট্রি ট্রাভার্সালের ধারণা — সেই কোর্স গ্রাফ ও ট্রি ট্রাভার্সাল আরও গভীরভাবে কভার করে।