মার্জিং — fast-forward বনাম three-way merge
এই পাঠে যা শিখবেন
- fast-forward merge ঠিক কখন ঘটে এবং কেন এতে নতুন কমিট লাগে না
- three-way merge-এর জন্য merge base কেন ও কীভাবে খোঁজা হয়
- একটি merge commit-এর "দুই parent থাকা" মানে ঠিক কী
- কোড দিয়ে যাচাই — দুটো ভিন্ন সিনারিও, দুটো ভিন্ন ফলাফল
১ · মার্জিং কী
মার্জিং মানে একটি branch-এর পরিবর্তনগুলো আরেকটি branch-এ একত্রিত করা — L34-এ তৈরি হওয়া স্বাধীন branch-গুলোকে আবার একসাথে নিয়ে আসার প্রক্রিয়া। git দুই ধরনের পরিস্থিতিতে দুই ভিন্নভাবে মার্জ করে, এবং কোনটা কখন ঘটবে তা সম্পূর্ণ নির্ভর করে দুই branch-এর কমিট-হিস্ট্রি একে অপরের সাথে ঠিক কীভাবে সম্পর্কিত তার ওপর।
২ · Fast-forward merge — সরল কেস
fast-forward mergeFast-forward Mergeঘটে যখন বর্তমান branch-এর tip কমিট মার্জ-হতে-যাওয়া branch-এর tip-এর একটি সরাসরি পূর্বপুরুষ -- git শুধু পয়েন্টার সামনে সরিয়ে দেয়, কোনো নতুন কমিট তৈরি করে না।
ঘটে যখন যে branch-এ মার্জ করা হচ্ছে (উদাহরণস্বরূপ main) মার্জ শুরু হওয়ার পর থেকে একদমই
নড়েনি — অর্থাৎ বর্তমান branch-এর tip কমিট, যেই branch-টি মার্জ করা হচ্ছে তার tip-এর একটি সরাসরি,
অবিচ্ছিন্ন পূর্বপুরুষ। এক্ষেত্রে git শুধু বর্তমান branch-এর পয়েন্টারকে সামনে সরিয়ে দেয়,
মিলিয়ে-নেওয়ার মতো কিছুই করার দরকার পড়ে না — এটাই সবচেয়ে সস্তা "মার্জ", আক্ষরিক অর্থে শুধু একটি পয়েন্টার
আপডেট।
৩ · Three-way merge — বিচ্যুত (diverged) কেস
three-way mergeThree-way Mergeদুই branch সত্যিই বিচ্যুত হয়ে গেলে দরকার হয় -- git merge base খুঁজে বের করে, তারপর প্রতিটি branch-এর পরিবর্তন merge base-এর সাথে তুলনা করে একত্রিত করে একটি দুই-parent-ওয়ালা merge commit তৈরি করে।
দরকার হয় যখন দুই branch সত্যিই বিচ্যুত হয়ে গেছে — প্রতিটির নিজস্ব কমিট আছে যা অন্যটিতে নেই।
এক্ষেত্রে git প্রথমে merge baseMerge Baseদুই branch-এর সবচেয়ে সাম্প্রতিক কমন পূর্বপুরুষ কমিট -- একটি lowest-common-ancestor-স্টাইল গ্রাফ সার্চ দিয়ে খোঁজা হয়।
(দুই branch-এর সবচেয়ে সাম্প্রতিক কমন পূর্বপুরুষ) খুঁজে বের করে — ../dsa/-এর tree/graph
traversal-এর সাথে সরাসরি সম্পর্কিত একটি lowest-common-ancestor-স্টাইল সার্চ। তারপর merge base-কে প্রতিটি
branch-এর tip-এর সাথে তুলনা করে প্রতিটি পাশে কী বদলেছে বের করে, এবং দুটো পরিবর্তনকে একত্রিত করে একটি
নতুন কমিট তৈরি করে — যার দুই parent থাকে (উভয় merge-করা branch-এর
tip) — একে বলা হয় merge commitMerge Commitদুই parent-ওয়ালা একটি কমিট -- three-way merge-এর ফলাফল, সাধারণ single-parent কমিট থেকে স্পষ্টভাবে আলাদা।,
যা সাধারণ single-parent কমিট থেকে স্পষ্টভাবে আলাদা।
কোন merge type হবে তা ডেভেলপার বেছে নেন না — এটি সম্পূর্ণভাবে নির্ভর করে দুই branch-এর কমিট-হিস্ট্রি
একে অপরের সাথে ঠিক কীভাবে সম্পর্কিত তার ওপর। git নিজেই is_ancestor-এর মতো একটি চেক চালিয়ে
স্বয়ংক্রিয়ভাবে সিদ্ধান্ত নেয় — এটাই কেন নিচের কোডে git_merge নিজেই প্রথমে fast-forward
সম্ভব কিনা যাচাই করে, তারপর প্রয়োজনে three-way merge-এ পড়ে।
৪ · কোড দিয়ে যাচাই
নিচের কোড সেলটি বাস্তব git বাইনারি চালায় না — এই ব্রাউজার-স্যান্ডবক্সে কোনো real git
ইনস্টল নেই — বরং এটি L34-এর branching মডেল সম্প্রসারণ করে find_merge_base ও
git_merge real git-এর অ্যালগরিদম যেভাবে কাজ করে ঠিক সেভাবেই বিশ্বস্তভাবে সিমুলেট করছে।
দুটো সম্পূর্ণ স্বতন্ত্র repo দিয়ে দুটো সিনারিও দেখানো হচ্ছে — একটিতে main একদমই নড়েনি
(fast-forward), আরেকটিতে দুটো branch-ই নিজস্ব কমিট পেয়েছে (three-way)। এই পাঠের merge-লজিক ধরে নিচ্ছে
দুই পাশের পরিবর্তন সাংঘর্ষিক (conflicting) নয় — একই লাইনে দুই পাশের ভিন্ন পরিবর্তন হলে কী হয়, সেটা
বিস্তারিতভাবে L36-এ।
# git-এর থ্রি-ট্রি + branching + merge মডেল (L30, L34 রিইউজ) -- এই স্যান্ডবক্সে real git নেই,
# তাই এটি git-এর অভ্যন্তরীণ আচরণের একটি বিশ্বস্ত সিমুলেশন, real git চালানো নয়।
def make_repo():
return {
"working_directory": {},
"staging_area": {},
"commits": {},
"branches": {"main": None},
"head": "main",
}
def current_commit_hash(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_hash(repo)
commit_hash = f"c{len(repo['commits']) + 1}"
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"] = {} # বাস্তব git-এর মতোই কমিটের পর staging area খালি হয়ে যায় (L30 রিইউজ)
return commit_hash
def git_branch(repo, name):
repo["branches"][name] = current_commit_hash(repo)
def git_switch(repo, branch_name):
repo["head"] = branch_name
target_hash = repo["branches"][branch_name]
snapshot = dict(repo["commits"][target_hash]["snapshot"]) if target_hash else {}
repo["working_directory"] = dict(snapshot)
repo["staging_area"] = dict(snapshot)
# ---- মার্জ-সংক্রান্ত হেল্পার ----
def ancestors(repo, commit_hash):
"""commit_hash থেকে parent অনুসরণ করে root পর্যন্ত সব পূর্বপুরুষের তালিকা (নিজেসহ)।"""
result = []
current = commit_hash
while current:
result.append(current)
current = repo["commits"][current]["parent"]
return result
def is_ancestor(repo, maybe_ancestor_hash, commit_hash):
return maybe_ancestor_hash in ancestors(repo, commit_hash)
def find_merge_base(repo, branch_a, branch_b):
"""দুই branch-এর সবচেয়ে সাম্প্রতিক কমন পূর্বপুরুষ -- LCA-স্টাইল গ্রাফ সার্চ (dsa-র সাথে সম্পর্কিত)।"""
hash_a = repo["branches"][branch_a]
hash_b = repo["branches"][branch_b]
ancestors_a = ancestors(repo, hash_a) # tip থেকে root পর্যন্ত, ক্রমানুসারে
ancestors_b_set = set(ancestors(repo, hash_b))
for h in ancestors_a:
if h in ancestors_b_set:
return h
return None
def git_merge(repo, source_branch):
target_branch = repo["head"]
target_hash = repo["branches"][target_branch]
source_hash = repo["branches"][source_branch]
if is_ancestor(repo, target_hash, source_hash):
# ফাস্ট-ফরওয়ার্ড: target এখনো source-এর ইতিহাসের ভেতরেই আছে, নড়েনি
repo["branches"][target_branch] = source_hash
repo["working_directory"] = dict(repo["commits"][source_hash]["snapshot"])
repo["staging_area"] = dict(repo["commits"][source_hash]["snapshot"])
return {"type": "fast-forward", "new_head": source_hash}
# থ্রি-ওয়ে মার্জ: merge base খুঁজে দুই পাশের পরিবর্তন একত্রিত করা (সরলীকৃত, conflict-free ধরে নেওয়া হচ্ছে)
base_hash = find_merge_base(repo, target_branch, source_branch)
base_snapshot = repo["commits"][base_hash]["snapshot"] if base_hash else {}
target_snapshot = repo["commits"][target_hash]["snapshot"]
source_snapshot = repo["commits"][source_hash]["snapshot"]
merged_snapshot = dict(source_snapshot)
for filename, target_content in target_snapshot.items():
if base_snapshot.get(filename) != target_content:
merged_snapshot[filename] = target_content # target-পাশে বদলানো ফাইল ধরে রাখা
new_hash = f"c{len(repo['commits']) + 1}"
repo["commits"][new_hash] = {
"message": f"Merge branch '{source_branch}' into {target_branch}",
"snapshot": merged_snapshot,
"parent": target_hash,
"parents": [target_hash, source_hash], # আসল দুই parent -- merge commit-এর প্রমাণ
}
repo["branches"][target_branch] = new_hash
repo["working_directory"] = dict(merged_snapshot)
repo["staging_area"] = dict(merged_snapshot)
return {"type": "three-way-merge", "new_head": new_hash, "merge_base": base_hash}
# ==== সিনারিও ১: ফাস্ট-ফরওয়ার্ড (main নড়েনি) ====
repo_ff = make_repo()
repo_ff["working_directory"]["readme.md"] = "# প্রজেক্ট"
git_add(repo_ff, "readme.md")
git_commit(repo_ff, "প্রাথমিক কমিট")
git_branch(repo_ff, "feature-x")
git_switch(repo_ff, "feature-x")
repo_ff["working_directory"]["feature.py"] = "def f(): return 1"
git_add(repo_ff, "feature.py")
git_commit(repo_ff, "ফিচার শুরু")
repo_ff["working_directory"]["feature.py"] = "def f(): return 2"
git_add(repo_ff, "feature.py")
git_commit(repo_ff, "ফিচার আপডেট")
git_switch(repo_ff, "main") # main এখনো প্রথম কমিটেই
result_ff = git_merge(repo_ff, "feature-x")
print("=== সিনারিও ১: ফাস্ট-ফরওয়ার্ড ===")
print("ফলাফল:", result_ff)
print("main pointer এখন:", repo_ff["branches"]["main"])
print("মোট কমিট সংখ্যা:", len(repo_ff["commits"]), "(কোনো নতুন merge commit তৈরি হয়নি!)")
# ==== সিনারিও ২: থ্রি-ওয়ে মার্জ (দুই branch-ই বিচ্যুত) ====
repo_dv = make_repo()
repo_dv["working_directory"]["readme.md"] = "# প্রজেক্ট ২"
git_add(repo_dv, "readme.md")
git_commit(repo_dv, "প্রাথমিক কমিট")
git_branch(repo_dv, "feature-y")
git_switch(repo_dv, "feature-y")
repo_dv["working_directory"]["feature.py"] = "def g(): pass"
git_add(repo_dv, "feature.py")
git_commit(repo_dv, "feature-y: feature.py যোগ")
git_switch(repo_dv, "main")
repo_dv["working_directory"]["readme.md"] = "# প্রজেক্ট ২\nmain-এ আপডেট হয়েছে"
git_add(repo_dv, "readme.md")
git_commit(repo_dv, "main: readme আপডেট")
result_dv = git_merge(repo_dv, "feature-y")
print("\n=== সিনারিও ২: থ্রি-ওয়ে মার্জ (বিচ্যুত) ===")
print("ফলাফল:", result_dv)
new_hash = result_dv["new_head"]
print("merge commit-এর parents ফিল্ড:", repo_dv["commits"][new_hash]["parents"])
print("merge commit-এর snapshot:", repo_dv["commits"][new_hash]["snapshot"])
print("মোট কমিট সংখ্যা:", len(repo_dv["commits"]), "(ঠিক ১টি নতুন merge commit যোগ হয়েছে)")
result_ff["type"] হবে "fast-forward", আর মোট কমিট সংখ্যা থাকে ৩ —
কোনো নতুন কমিট যোগ হয়নি, শুধু main পয়েন্টার সরাসরি c3-তে চলে গেছে। সিনারিও ২-এ
result_dv["type"] হবে "three-way-merge", merge_base হবে
প্রথম কমিটের হ্যাশ, আর merge commit-এর parents ফিল্ডে ঠিক দুটো হ্যাশ থাকবে —
main-এর tip আর feature-y-এর tip, উভয়ই — সরাসরি প্রমাণ করছে এটি একটি
দুই-parent-ওয়ালা merge commit। merged snapshot-এ readme.md-এর main-এর আপডেট এবং feature.py দুটোই
আছে, কোনো একটাও হারিয়ে যায়নি।
fast-forward আর three-way merge-এর পার্থক্য একটি একক প্রশ্নে সংক্ষিপ্ত করা যায় — "target branch কি source branch-এর history-র ভেতরেই আছে?" হ্যাঁ হলে শুধু পয়েন্টার সরান (সস্তা); না হলে merge base খুঁজে দুই parent-ওয়ালা একটি নতুন কমিট বানান (ব্যয়বহুল, কিন্তু প্রয়োজনীয়)। এই দ্বিতীয় কেসেই আসল জটিলতা শুরু হয় — যখন দুই branch একই লাইনে ভিন্নভাবে পরিবর্তন করে, যা L36-এ merge conflict হিসেবে আসবে।
ভাবনার প্রশ্ন
প্রতিটি প্রশ্ন নিজে কিছুক্ষণ ভাবুন — তারপর "→ উত্তর" চাপুন।
প্র ০১
fast-forward merge-এ কেন কোনো নতুন কমিট তৈরি হয় না — is_ancestor ফাংশনটা ঠিক কী চেক করছে?
is_ancestor(repo, target_hash, source_hash) চেক করে target_hash কি
source_hash-এর ancestors() তালিকায় আছে কিনা — অর্থাৎ target branch-এর tip
কি source branch-এর history-র মধ্যেই "পথের ওপর" পড়ে। যদি হ্যাঁ হয়, তার মানে target-এ এমন কোনো
কমিট নেই যা source-এ নেই — একত্রিত করার মতো কিছু নেই, শুধু target-কে source-এর tip পর্যন্ত এগিয়ে
দিলেই যথেষ্ট, তাই নতুন কমিটের প্রয়োজনই পড়ে না।
প্র ০২
find_merge_base কেন main-এর ancestor chain "tip থেকে পেছনে" হেঁটে চেক করে, feature-branch-এর নয় — এটা কি ফলাফল পাল্টায়?
না, ফলাফল পাল্টায় না — merge base একটি প্রতিসম (symmetric) ধারণা, কোন branch-কে "a" আর কোনটাকে "b"
বলা হচ্ছে তাতে কিছু যায় আসে না। কোডে ancestors_a-কে ক্রমানুসারে (tip থেকে root) হাঁটার
কারণ হলো — এতে প্রথম যে কমন কমিটটি পাওয়া যায় সেটিই সবচেয়ে সাম্প্রতিক কমন পূর্বপুরুষ
(কারণ আমরা সময়ের বিপরীতে হাঁটছি) — ancestors_b-কে set-এ রাখা শুধু
মেম্বারশিপ-চেক দ্রুত করার জন্য, কোনো ক্রম প্রয়োজন নেই সেখানে।
প্র ০৩
merge commit-এর parents ফিল্ডে ["c3", "c2"]-এর মতো দুটো হ্যাশ থাকার মানে কী — git log-এর মতো একটি ট্রাভার্সালে এই দুই parent কীভাবে ব্যবহার হতে পারে?
এর মানে merge commit-টি দুটো স্বতন্ত্র ইতিহাসের সমন্বয় — c3 (main-এর দিকের ইতিহাস) এবং c2
(feature-y-এর দিকের ইতিহাস) উভয়ের সরাসরি ধারাবাহিকতা। একটি পূর্ণাঙ্গ git log-এর মতো
ট্রাভার্সাল উভয় parent-কেই অনুসরণ করবে (আমাদের সরলীকৃত git_log শুধু প্রাইমারি
parent ফিল্ড অনুসরণ করে সরল রাখার জন্য) — বাস্তব git ঠিক এভাবেই একটি merge commit থেকে
দুই দিকেই শাখা-প্রশাখায় গিয়ে সম্পূর্ণ combined history দেখাতে পারে।
অনুশীলন
-
পরীক্ষা করুন: সিনারিও ১-এর
git_merge(repo_ff, "feature-x")কল করার আগে main-এ একটা নতুন কমিট যোগ করুন (readme.md পরিবর্তন করেgit_add/git_commit) — এখন কি এখনো fast-forward হবে?না — এখন main-এর নিজস্ব একটি কমিট আছে যা feature-x-এর history-তে নেই, তাই
is_ancestor(repo, target_hash, source_hash)False রিটার্ন করবে (main-এর নতুন tip আর feature-x-এর tip-এর ancestor chain-এ নেই)। এখনgit_mergeautomatically three-way merge-এ পড়ে যাবে,find_merge_baseখুঁজে বের করবে, এবং একটি দুই-parent merge commit তৈরি করবে — ঠিক সিনারিও ২-এর মতো। -
চিন্তা করুন: দুই branch যদি একই ফাইলের একই লাইনে ভিন্ন পরিবর্তন করে, আমাদের সরলীকৃত
merged_snapshotলজিক (যা শুধু ফাইল-লেভেলে target-এর ভার্সন ধরে রাখে) কী ভুল ফলাফল দিতে পারে?আমাদের লজিক পুরো ফাইল-লেভেলে কাজ করে — যদি target ও source দুজনেই একই ফাইল বদলায়, বর্তমান কোড নিঃশর্তে
target_content-কে জিতিয়ে দেয়, source-এর সেই ফাইলের পরিবর্তন সম্পূর্ণ হারিয়ে যায় — কোনো সতর্কতা বা conflict চিহ্নিতকরণ ছাড়াই! এটাই ঠিক সেই ফাঁক যা L36-এ পূরণ করা হবে — একটি লাইন-বাই-লাইন তুলনা যা সত্যিকারের conflict শনাক্ত করে এবং conflict marker যোগ করে, শুধু একপাশকে নিঃশব্দে জিতিয়ে দেওয়ার বদলে।
আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ
- কোর্সের সম্পূর্ণ সিলেবাস দেখুন ৫৮টি পাঠ পরবর্তী পাঠ — মার্জ কনফ্লিক্ট ও সমাধান — খুব শীঘ্রই।
- L34 · Git ব্রাঞ্চিং বেসিকস পূর্বশর্ত branch যে আসলে শুধুই একটি পয়েন্টার তা না বুঝলে fast-forward কেন "শুধু পয়েন্টার সরানো" তা বোঝা কঠিন।
-
Data Structures & Algorithms কোর্স সম্পর্কিত কোর্স
find_merge_base-এর LCA-স্টাইল সার্চ সরাসরি সেই কোর্সের tree/graph traversal অ্যালগরিদমের একটি বাস্তব প্রয়োগ।