পাঠ ৩৫ · ৫৮-এর মধ্যে · মডিউল ৮
Home / Courses / Software Engineering Principles & Git / মার্জিং — fast-forward বনাম three-way merge

মার্জিং — fast-forward বনাম three-way merge

Merging — fast-forward vs three-way merge
৮ মিনিট পড়া মধ্যম · Intermediate Python কোডসহ সম্পূর্ণ বাংলায়

এই পাঠে যা শিখবেন

  • 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 কমিট থেকে স্পষ্টভাবে আলাদা।

ফাস্ট-ফরওয়ার্ড c1 c2 (feature-x) c3 (feature-x) main → সরাসরি c3 নতুন কমিট তৈরি হয় না থ্রি-ওয়ে মার্জ (বিচ্যুত) c1 (base) c3 (main) c2 (feature-y) c4 (merge, ২ parent)
ফাস্ট-ফরওয়ার্ডে main শুধু সরাসরি c3-কে পয়েন্ট করে দেয়। থ্রি-ওয়ে মার্জে c1 হলো merge base, আর c4 একটি নতুন কমিট যার দুই parent — c3 ও c2 উভয়ই।
কখন কোনটা — সিদ্ধান্ত সম্পূর্ণ ইতিহাসনির্ভর

কোন 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-এ।

Python
# 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 দুটোই আছে, কোনো একটাও হারিয়ে যায়নি।
মূল কথা · Key takeaway

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 দেখাতে পারে।

অনুশীলন

  1. পরীক্ষা করুন: সিনারিও ১-এর 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_merge automatically three-way merge-এ পড়ে যাবে, find_merge_base খুঁজে বের করবে, এবং একটি দুই-parent merge commit তৈরি করবে — ঠিক সিনারিও ২-এর মতো।

  2. চিন্তা করুন: দুই branch যদি একই ফাইলের একই লাইনে ভিন্ন পরিবর্তন করে, আমাদের সরলীকৃত merged_snapshot লজিক (যা শুধু ফাইল-লেভেলে target-এর ভার্সন ধরে রাখে) কী ভুল ফলাফল দিতে পারে?

    আমাদের লজিক পুরো ফাইল-লেভেলে কাজ করে — যদি target ও source দুজনেই একই ফাইল বদলায়, বর্তমান কোড নিঃশর্তে target_content-কে জিতিয়ে দেয়, source-এর সেই ফাইলের পরিবর্তন সম্পূর্ণ হারিয়ে যায় — কোনো সতর্কতা বা conflict চিহ্নিতকরণ ছাড়াই! এটাই ঠিক সেই ফাঁক যা L36-এ পূরণ করা হবে — একটি লাইন-বাই-লাইন তুলনা যা সত্যিকারের conflict শনাক্ত করে এবং conflict marker যোগ করে, শুধু একপাশকে নিঃশব্দে জিতিয়ে দেওয়ার বদলে।

আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ

আগের পাঠ
L34 · Git ব্রাঞ্চিং বেসিকস