يبدأ أغلب الفرق العمل بالفروع Branches بنية حسنة، فكل مطور يفتح فرعاً لميزته حتى لا يكسر عمل غيره، ثم تمر الأيام وتصبح الأيام أسابيع، وفي يوم الإصدار يحاول الفريق أن يجمع كل هذه الفروع في فرع واحد فيكتشف أن كل فرع منها كتب بافتراضات تغيرت منذ مدة، وأن الدمج Merge الذي كان يظنه خطوة من دقائق صار يوماً كاملاً من حل التعارضات Conflicts، وأن الأخطاء التي ظهرت بعد الدمج لم يرها أحد قبل ذلك لأن كل فرع كان يختبر وحده. وفي الوقت نفسه يسأل فريق الاختبار QA سؤالاً منطقياً: ما الذي نختبره بالضبط؟ هل نختبر فرع الميزة كما هو، مع أنه لن يصدر بهذا الشكل بعد أن يدمج مع غيره؟
هذه المشكلة يعرفها كل فريق برمجيات تقريباً، والحل الذي توصي به أبحاث DORA ويطبقه كثير من الفرق هو التطوير على الفرع الرئيسي Trunk-Based Development، أي أن يدمج كل مطور عمله في فرع واحد مشترك كل يوم على الأقل، بدل أن يعيش في فرعه أسابيع. وإذا كانت أوامر Git نفسها جديدة عليك فابدأ بمقال إدارة النسخ وGit للمبتدئين، وإذا أردت تفاصيل الدمج وإعادة الأساس Rebase ونسخ commit واحد Cherry-Pick فهي في Git Merge وRebase وCherry-Pick، أما هذا المقال فيتحدث عن طريقة تنظيم الفروع نفسها، وسوف نناقش فيه ما يلي:
- لماذا تسبب الفروع طويلة العمر Long-Lived Branches كل هذه المشكلات، مع مثال حقيقي يظهر التعارضات يوم الإصدار.
- ما هو Trunk-Based Development بتعريف مصادره، وما الذي وجدته أبحاث DORA.
- ما الذي يجعله آمناً: ال CI السريع، وحماية الفرع Branch Protection، وطلبات الدمج الصغيرة، وال Feature Flags، وBranch by Abstraction، والإصدار من الفرع الرئيسي.
- مقارنة صريحة مع Git Flow وGitHub Flow، ومتى يناسب كل منها.
- مثال عملي كامل بمطورين وفروع قصيرة وFeature Flag ووسم إصدار وإصلاح ينسخ إلى فرع الإصدار، بمخرجات Git حقيقية.
- كيف تنقل فريقك من Git Flow خطوة بخطوة، والجانب الثقافي في الفريق، ثم الأخطاء الشائعة.
المشكلة: فروع تعيش أسابيع
لنفرض أن لديك تطبيق متجر صغيراً، وأن الفريق يعمل بطريقة Git Flow المعروفة، أي أن هناك فرعاً اسمه main لما هو في الإنتاج Production، وفرعاً اسمه develop يجمع العمل، ولكل ميزة فرع يخرج من develop ويعود إليه عندما تكتمل. المطور الأول يعمل على كوبونات الخصم في feature/coupons، والثاني يضيف ضريبة القيمة المضافة VAT في feature/vat، وكلاهما يعدل الملف نفسه الذي يحسب المجموع، وخلال ذلك يظهر خطأ في الإنتاج فيصلح في فرع hotfix/1.0.1. والصورة التالية تبين ما يحدث لهذه الفروع خلال ثلاثة أسابيع:

لاحظ في الصورة أن المشكلة ليست في Git نفسه، وإنما في المدة التي يبقى فيها العمل منفصلاً، وهذه المدة تولد خمس مشكلات يعرفها كل من عمل في فريق:
- جحيم الدمج Merge Hell: كل فرع يتغير وحده أسابيع، وعندما يحين الدمج يجد المطور أن الكود الذي بنى عليه تغير، فيحل التعارضات وهو لا يتذكر لماذا كتب زميله ما كتبه.
- أخطاء التكامل Integration Bugs تكتشف متأخرة: كل فرع ينجح في اختباراته وحده، والخطأ يظهر فقط عندما تجتمع الفروع، أي في آخر يوم وتحت ضغط موعد الإصدار.
- فريق QA يختبر شيئاً لن يصدر: فرع
feature/vatالذي اختبره الفريق يختلف عن الكود الذي سيصدر بعد دمجه مع الكوبونات والإصلاح العاجل، لذلك يعاد الاختبار بعد الدمج، أو لا يعاد ويخرج الخطأ إلى الإنتاج. - فروع إصدار لا تعود: فرع الإصدار Release Branch يفتح للتثبيت ثم تضاف عليه إصلاحات، وإذا نسي أحد أن يعيدها إلى
developعاد الخطأ في الإصدار التالي. - الإصلاح العاجل يطبق مرتين: قاعدة Git Flow أن فرع ال Hotfix يدمج في
mainوفيdevelopمعاً، وكل دمج مزدوج فرصة لتعارض جديد أو لنسيان أحد الطرفين.
التعارضات يوم الإصدار بمخرجات حقيقية
حتى لا يبقى الكلام نظرياً، قمنا ببناء السيناريو أعلاه بسكربت في Container من Alpine 3.24 مع Git 2.54.0: ملف checkout.sh يطبع المجموع، وفرع الكوبونات بثلاثة commits على مدى ثلاثة أسابيع، وفرع الضريبة بـ commitين، وإصلاح عاجل يجعل المبلغ إلزامياً ويدمج في main وdevelop كما تطلب Git Flow. وفي يوم الإصدار نقوم بدمج الفرعين في develop:
git switch develop
git merge --no-ff feature/vat -m "Merge feature/vat into develop"والمخرج سوف يكون كما يلي:
Auto-merging checkout.sh
CONFLICT (content): Merge conflict in checkout.sh
Automatic merge failed; fix conflicts and then commit the result.وعند عرض التعارض بالأمر git diff تجد أن الإصلاح العاجل الذي دمج قبل أسبوعين يتعارض مع سطر غيره فرع الضريبة:
diff --cc checkout.sh
index 091ba1f,9d92d81..0000000
--- a/checkout.sh
+++ b/checkout.sh
@@@ -1,4 -1,6 +1,11 @@@
#!/bin/sh
# Usage: ./checkout.sh <amount>
++<<<<<<< HEAD
+amount="${1:?amount is required}"
+echo "total: $amount"
++=======
+ amount="$1"
+ total=$(awk -v a="$amount" 'BEGIN { printf "%.2f", a * 1.15 }')
+ echo "total: $total"
+ # vat: 15% (week 2)
++>>>>>>> feature/vatبعد أن يحل أحد أفراد الفريق هذا التعارض يدوياً، ننتقل إلى الفرع الثاني:
git merge --no-ff feature/coupons -m "Merge feature/coupons into develop"
git diffوالمخرج سوف يكون كما يلي:
Auto-merging checkout.sh
CONFLICT (content): Merge conflict in checkout.sh
Automatic merge failed; fix conflicts and then commit the result.
diff --cc checkout.sh
index 46e1452,9c051aa..0000000
--- a/checkout.sh
+++ b/checkout.sh
@@@ -1,6 -1,6 +1,14 @@@
#!/bin/sh
++<<<<<<< HEAD
+# Usage: ./checkout.sh <amount>
+amount="${1:?amount is required}"
+total=$(awk -v a="$amount" 'BEGIN { printf "%.2f", a * 1.15 }')
+echo "total: $total"
+# vat: 15% (week 2)
++=======
+ # Usage: ./checkout.sh <amount> [coupon]
+ amount="$1"
+ [ "$2" = "SAVE10" ] && amount=$((amount * 90 / 100))
+ echo "total: $amount"
+ # coupons: SAVE10 only (week 3)
++>>>>>>> feature/couponsفي المخرج أعلاه لاحظ التالي:
- التعارض الثاني أصعب من الأول، لأن فرع الكوبونات بني على نسخة لا تعرف شيئاً عن الضريبة ولا عن الإصلاح العاجل، فمن يحل التعارض يجب أن يفهم الميزتين معاً.
- هناك مشكلة لا يظهرها Git أصلاً: الكوبونات تحسب بأعداد صحيحة
$((amount * 90 / 100))، والضريبة تحسب بالكسور العشرية، وحتى لو حل التعارض نصياً فقد ينتج كود يعمل ويعطي أرقاماً خاطئة، وهذا هو خطأ التكامل الذي يكتشف متأخراً. - لو دمج كل مطور عمله في اليوم الأول لظهر هذا الاختلاف في سطرين، وقت أن كانت التغييرات صغيرة وفي ذاكرة صاحبها.
وهذا هو شكل التاريخ History قبل الدمج، بالأمر git log --graph --oneline --all:
* 5cf99c2 Merge feature/vat into develop
|\
| * 2a5883c vat: week 2
| * 918fadc vat: week 1, add 15% VAT
* | 39338d4 Merge hotfix/1.0.1 into develop
|\ \
| |/
|/|
| | * 74a32f8 Merge hotfix/1.0.1 into main
| |/|
|/|/
| * 9857a4e hotfix: fail when amount is missing
|/
| * 47a6013 coupons: week 3
| * d91e23a coupons: week 2
| * aa289fb coupons: week 1, SAVE10 code
|/
* d1a2ac3 Initial checkoutالحلول الأولى التي تخطر على البال، ولماذا لا تكفي
- أن يدمج كل مطور develop في فرعه كل يوم: هذا يقلل المفاجأة من جهة واحدة فقط، فأنت تأخذ ما وصل إلى
develop، ولكن زميلك الذي يعمل في فرعه منذ أسبوعين لم يدمج شيئاً بعد، فالتعارض بينكما ما زال مؤجلاً. - تجميد الكود Code Freeze قبل الإصدار: يوقف الفريق إضافة أي ميزة أسبوعاً ليثبت ما لديه، وهذا يعالج العرض لا السبب، والأبحاث تقول العكس كما سيأتي، فالفرق الأعلى أداء لا تجمد الكود ولا تخصص مرحلة للتكامل.
- أسبوع للتكامل Integration Phase: يخصص الفريق آخر أيام الدورة لدمج الفروع وإصلاح ما ينكسر، وهي أسوأ الطرق على الإطلاق، لأنها تجمع كل المخاطر في أضيق وقت.
والحل الأنسب هو أن لا يتراكم الكود غير المدمج أصلاً، وهذا ما يفعله Trunk-Based Development.
ما هو Trunk-Based Development؟
يعرفه موقع trunkbaseddevelopment.com، وهو المرجع الذي يجمع ممارساته، بأنه نموذج لإدارة الفروع في نظام التحكم بالنسخ يتعاون فيه المطورون على الكود في فرع واحد يسمى الجذع Trunk، ويقاومون أي ضغط لإنشاء فروع تطوير أخرى طويلة العمر باستخدام تقنيات موثقة. والجذع في Git هو عادة الفرع main، ويضيف الموقع أن كل أفراد الفريق يدمجون في الجذع مرة واحدة على الأقل كل 24 ساعة، وهذا هو الحد الأدنى لما يسمى التكامل المستمر Continuous Integration بمعناه الأصلي، أي أن الكلمة تصف عادة في الفريق قبل أن تصف أداة تشغل الاختبارات.
وتعرفه صفحة Trunk-Based Development في موقع DORA بأن كل مطور يقسم عمله إلى دفعات صغيرة Small Batches ويدمجها في الجذع مرة على الأقل في اليوم، وربما عدة مرات. وDORA هو برنامج البحث الذي يقف خلف تقارير State of DevOps وكتاب Accelerate الصادر في 2018 لـ Nicole Forsgren وJez Humble وGene Kim، وتذكر الصفحة أن الفرق تحقق أداء أعلى في التسليم والتشغيل عندما:
- يكون في مستودع التطبيق ثلاثة فروع نشطة أو أقل.
- تدمج الفروع في الجذع مرة على الأقل في اليوم.
- لا يوجد تجميد للكود ولا مراحل مخصصة للتكامل.
وهناك طريقتان للعمل بهذا الأسلوب، والاختيار بينهما يعتمد على حجم الفريق وطبيعته:
- الدمج المباشر في الجذع: يدفع المطور Push إلى
mainمباشرة بعد أن تنجح الاختبارات على جهازه، وهذا يناسب فريقاً صغيراً جداً يعمل بالبرمجة الثنائية Pair Programming أو لا يحتاج مراجعة رسمية. - الفروع قصيرة العمر Short-Lived Feature Branches: وهي الطريقة الشائعة في أغلب الفرق، فالمطور يفتح فرعاً صغيراً ويرفعه بطلب دمج Pull Request يمر بالمراجعة Code Review وبال CI ثم يدمج ويحذف. ويحدد trunkbaseddevelopment.com عمر هذا الفرع بيومين على الأكثر، وإذا زاد عن ذلك صار في خطر أن يتحول إلى فرع طويل العمر، ويعمل عليه مطور واحد، أو اثنان إذا كانا يبرمجان معاً.
والصورة التالية تبين الشكل الذي يصبح عليه العمل في هذه الطريقة:

والقاعدة التي تجمع كل ذلك أن الفرع main قابل للإصدار دائماً Always Releasable، أي أن أي commit عليه نجح في ال CI يمكن أن يصدر في أي لحظة، والسبب أن الفريق لم يعد يجمع العمل في فرع وسيط مثل develop ثم يثبته، فإذا لم يكن main صالحاً للإصدار فلا يوجد مكان آخر يصلح لذلك.
وقد يتساءل البعض: كيف أدمج كل يوم ميزة تحتاج أسبوعين من العمل، دون أن يرى المستخدمون نصف ميزة؟ والإجابة أن الميزة نفسها تقسم إلى أجزاء صغيرة كل منها يدمج وهو سليم، والجزء الذي لم يكتمل يبقى مخفياً عن المستخدمين خلف Feature Flag أو خلف واجهة لم تربط بعد، وهذا ما نشرحه في القسم التالي.
ما الذي يجعل الدمج اليومي آمناً؟
الدمج كل يوم في فرع يصدر منه الإنتاج يبدو خطيراً من الوهلة الأولى، وهو كذلك فعلاً إذا لم تتوفر الممارسات التالية، فهي ليست إضافات اختيارية، وإنما هي ما يجعل الطريقة تعمل أصلاً.
CI سريع على كل commit وكل طلب دمج
كل Push إلى فرع، وكل Pull Request، يشغل خطاً آلياً Pipeline يفحص التنسيق Lint ويشغل الاختبارات Tests ويبني التطبيق Build، ولا يدمج شيء في main قبل أن ينجح هذا الخط. ومن الأخطاء الشائعة التي تذكرها DORA في صفحتها الدمج دون اختبارات آلية، فالاختبارات يجب أن تعمل على التغيير قبل دمجه، وتوصي بأن تنتهي عملية البناء والاختبار في دقائق قليلة، والسبب أن المطور الذي ينتظر نصف ساعة سوف يجمع تغييرات أكبر ليقلل عدد مرات الانتظار، وبالتالي تعود الفروع الكبيرة من الباب الخلفي. وقد شرحنا تشغيل خط CI على سيرفرك في دليل Gitea Actions لتشغيل CI/CD على السيرفر.
حماية الفرع main
القاعدة المكتوبة في ملف README لا تمنع أحداً من الدفع مباشرة إلى main في لحظة استعجال، لذلك اجعلها قاعدة يفرضها السيرفر. وفي Gitea، الذي شرحنا تثبيته في دليل تثبيت Gitea، تجد هذه القواعد في إعدادات المستودع تحت Settings ثم Branches، وتوثق صفحة Protected Branches خياراتها، وأهمها لهذه الطريقة:
- منع الدفع المباشر Disable Push: فكل تغيير يصل إلى
mainعبر Pull Request. - منع الدفع القسري Disable Force Push: حتى لا يعاد كتابة تاريخ الفرع الذي يعتمد عليه الجميع.
- عدد الموافقات المطلوبة Required Approvals: موافقة واحدة كافية لأغلب الفرق، وزيادتها تبطئ الدمج كما سيأتي.
- فحص الحالة Status Check: تحدد فيه أسماء مهام ال CI التي يجب أن تنجح قبل أن يسمح بالدمج.
- منع الدمج إذا كان الطلب متأخراً عن الفرع Block merge if the pull request is outdated: فيضطر صاحب الطلب إلى تحديثه على آخر
main، وبالتالي يختبر ال CI الكود كما سيكون بعد الدمج. - إلزام المدراء Administrators must follow branch protection rules: حتى لا يصبح حساب المدير طريقاً مختصراً حول القواعد.
طلبات دمج صغيرة ومراجعة سريعة
طلب الدمج الذي يغير ثلاثين ملفاً لا يراجعه أحد حقيقة، وإنما يوافق عليه بعد نظرة سريعة، أما الذي يغير ملفين فيقرأ سطراً سطراً في دقائق. وتذكر DORA من الأخطاء الشائعة عملية مراجعة ثقيلة تتطلب موافقات كثيرة، ومراجعة غير متزامنة تترك الطلب ينتظر، فكلما تأخر الدمج زادت احتمالات التعارض، وتوصي بالمراجعة المتزامنة Synchronous Review، أي أن يراجع الزميل الطلب قريباً من وقت فتحه، أو أن يراجعه مع صاحبه مباشرة.
Feature Flags: ادمج الكود واطلق الميزة لاحقاً
ال Feature Flag، ويسمى أيضاً Feature Toggle، شرط في الكود يقرر في وقت التشغيل Runtime هل يعمل المسار الجديد أم القديم، وقيمته تأتي من متغير بيئة Environment Variable أو من خدمة مخصصة. وبهذا يصبح الكود غير المكتمل جزءاً من main ومن كل إصدار، ولكنه لا يعمل لأحد حتى تقرر تشغيله، وهذا يفصل بين أمرين كانا مرتبطين: النشر Deployment، أي نقل الكود إلى السيرفر، والإطلاق Release، أي أن يرى المستخدمون الميزة. والصورة التالية تبين ذلك:

ويصنف Pete Hodgson في مقاله المرجعي Feature Toggles ال Flags إلى أنواع، أهمها لهذا المقال Release Toggles التي تسمح بشحن مسارات غير مكتملة إلى الإنتاج ككود كامن قد لا يشغل أبداً، وOps Toggles التي تتحكم في سلوك النظام. ومن هذه الفكرة تخرج ثلاثة استخدامات يحتاجها كل فريق:
- الإطلاق المظلم Dark Launch: الكود في الإنتاج وال Flag مطفأ، أو مفعل لفريق العمل وفريق QA فقط، فيختبرون الميزة على البيانات الحقيقية قبل أي مستخدم.
- الإطلاق التدريجي Gradual Rollout: تشغل الميزة لنسبة صغيرة من المستخدمين ثم تزيدها، وإذا ظهرت مشكلة فهي تصيب عدداً قليلاً.
- Kill Switch: إذا سببت الميزة مشكلة في الإنتاج تطفئها في ثوان، دون بناء جديد ودون نشر جديد.
وفي البداية يكفي متغير بيئة كما في المثال العملي أدناه، وعندما يزيد عدد ال Flags وتحتاج نسباً ومجموعات مستخدمين وسجلاً لمن غير ماذا، تنتقل إلى أداة مخصصة مثل Unleash أو Flagsmith أو GrowthBook، وهي مخطط لها في مرحلة منصات للمطورين في خارطة الطريق. أما تفعيل ال Flag في بيئة dev أو staging دون الإنتاج فيتعلق بالبيئات نفسها، وهو مشروح في مقال البيئات ومعاينة الميزات وGitOps للفرق.
التغييرات الكبيرة: Branch by Abstraction وKeystone Interface
بعض التغييرات لا تختبئ خلف شرط واحد، مثل استبدال مكتبة الدفع أو طبقة الوصول إلى قاعدة البيانات، والحل البسيط هو فرع يعيش شهراً حتى يكتمل الاستبدال، وهو بالضبط ما نحاول تجنبه. والطريقة البديلة اسمها Branch by Abstraction، وقد سماها Stacy Curl في 2007، وخطواتها كما يلي:
- أضف طبقة تجريد Abstraction حول الكود الذي سوف تستبدله، واجعل كل من يستخدمه يمر عبرها، وادمج ذلك في
main. - اكتب التنفيذ الجديد Implementation خلف الطبقة نفسها، وادمجه تدريجياً وهو مطفأ.
- شغل التنفيذ الجديد لبقية الفريق ثم للمستخدمين.
- احذف التنفيذ القديم.
- احذف طبقة التجريد إذا لم تعد تحتاجها.
وهناك طريقة أبسط لكثير من الميزات يسميها Martin Fowler Keystone Interface، وهي أن تبني كل الكود الخلفي Back-End وتدمجه وتختبره، وتؤجل الواجهة التي يراها المستخدم إلى النهاية، فهي مثل حجر القفل في القوس، توضع أخيراً فتكتمل الميزة وتظهر للمستخدمين، وهذا ما يفعله المطور الأول في المثال العملي: محرك الكوبونات يدمج في اليوم الأول دون أن يستدعيه أحد، والربط بصفحة الدفع يأتي في اليوم الثاني.
الإصدار من الفرع الرئيسي: وسوم أو فرع إصدار قصير
عندما يكون main صالحاً للإصدار دائماً فهناك طريقتان للإصدار، والصورة التالية تبينهما:

- الإصدار بوسم Tag من main: تضع وسماً مثل
v1.3.0على commit نجح في ال CI بالأمرgit tag، والنشر يأخذ هذا ال commit تحديداً. وإذا ظهر خطأ فإنه يصلح علىmainويصدر إصدار جديد، وهذا ما يسمى الإصلاح إلى الأمام Fix Forward، ويذكر trunkbaseddevelopment.com أن الفرق التي تصدر كثيراً لا تحتاج فروع إصدار أصلاً لهذا السبب. وهذه الطريقة تناسب تطبيقات الويب وخدمات SaaS التي فيها نسخة واحدة في الإنتاج. - فرع إصدار قصير Release Branch: تفتح فرعاً مثل
release/1.1من وسم الإصدار، فقط عندما تحتاج تثبيت إصدار بينما يستمر العمل علىmain، مثل تطبيق جوال ينتظر مراجعة متجر التطبيقات أو منتج يثبت عند العملاء On-Premises ويدعم أكثر من إصدار. والقاعدة هنا كما في الموقع نفسه: أعد إنتاج الخطأ على الجذع، وأصلحه هناك مع اختبار، وانتظر نجاح ال CI، ثم انسخ الإصلاح إلى فرع الإصدار بالأمرgit cherry-pick، والسبب أن الإصلاح الذي يكتب على فرع الإصدار أولاً قد ينسى نقله إلى الجذع، فيعود الخطأ إلى الإنتاج بعد أسابيع مع الإصدار التالي. وفرع الإصدار لا يدمج في الجذع أبداً، ويحذف بعد انتهاء دعم ذلك الإصدار.
وعند النسخ استخدم الخيار -x، والذي يضيف بحسب توثيق git cherry-pick سطراً في رسالة ال commit يذكر ال commit الأصلي الذي نسخ منه، فيعرف من يقرأ تاريخ فرع الإصدار من أين جاء كل إصلاح. وتفاصيل Cherry-Pick نفسه وتعارضاته في Git Merge وRebase وCherry-Pick.
Trunk-Based أم Git Flow أم GitHub Flow؟
هذه الطرق الثلاث هي الأكثر انتشاراً، وكل منها صمم لنوع مختلف من المنتجات، والصورة التالية تضعها جنباً إلى جنب:

Git Flow
نشر Vincent Driessen نموذج A successful Git branching model في يناير 2010، وفيه فرعان دائمان هما main (كان اسمه master) وdevelop، وفروع مساعدة للميزات والإصدارات والإصلاحات العاجلة، وفروع الإصدار والإصلاح تدمج في الفرعين الدائمين معاً. وفي مارس 2020 أضاف الكاتب نفسه ملاحظة في أعلى المقال يذكر فيها أن النموذج صمم لبرمجيات لها أرقام إصدارات وتدعم أكثر من نسخة في الوقت نفسه، وأن الفريق الذي يعمل بالتسليم المستمر Continuous Delivery عليه أن يختار سير عمل أبسط بكثير مثل GitHub Flow بدل أن يجبر فريقه على Git Flow، وختم بأنه لا يوجد حل لكل شيء، فاعتبر سياقك وقرر بنفسك.
نقاط القوة: ترتيب واضح لمنتج يصدر بأرقام إصدارات ويدعم عدة إصدارات عند العملاء، ومكان واضح لكل نوع من العمل.
نقاط الضعف: فروع تعيش أسابيع ودمج مزدوج للإصلاحات، وفرع develop يصبح هو الذي يختبر بينما يصدر main، فيتكرر الاختبار، وكل ما رأيناه في المثال أعلاه.
GitHub Flow
تصفه صفحة GitHub Flow بأنه سير عمل خفيف يعتمد على الفروع، وخطواته: أنشئ فرعاً، ثم اعمل تغييراتك، ثم افتح Pull Request، ثم عالج ملاحظات المراجعة، ثم ادمج، ثم احذف الفرع. ويوجد فيه فرع دائم واحد هو main، ولكنه لا يحدد عمر الفرع، فقد يبقى طلب الدمج مفتوحاً أسبوعين.
نقاط القوة: بسيط ويفهمه أي مطور في يوم واحد، ومناسب لتطبيقات الويب التي تنشر بعد كل دمج.
نقاط الضعف: لا يقول شيئاً عن الميزة التي تحتاج أسابيع، فإذا لم يضع الفريق حداً لعمر الفرع عاد إلى مشكلات الفروع الطويلة نفسها.
Trunk-Based Development
هو في الواقع GitHub Flow مع قاعدتين إضافيتين: الفرع يعيش ساعات أو يوماً أو يومين، والعمل غير المكتمل يدمج مخفياً خلف Feature Flag، مع فروع إصدار قصيرة عند الحاجة فقط.
نقاط القوة: تكامل يومي فلا توجد مفاجآت يوم الإصدار، وفريق QA يختبر ما سوف يصدر فعلاً، وكل إصلاح يكتب مرة واحدة.
نقاط الضعف: لا يعمل بدون CI سريع واختبارات موثوقة، ويحتاج انضباطاً في تقسيم العمل، وال Flags نفسها تتحول إلى دين تقني Flag Debt إذا لم تحذف.
الفرق في جدول واحد
| Git Flow | GitHub Flow | Trunk-Based | |
|---|---|---|---|
| الفروع الدائمة | main وdevelop | main | main، وفرع إصدار قصير عند الحاجة |
| عمر فرع الميزة | أيام إلى أسابيع | حتى تنتهي المراجعة، بدون حد | ساعات، ويومان على الأكثر |
| إيقاع الإصدار | إصدارات مخططة عبر فرع release | نشر بعد كل دمج | أي commit نجح في ال CI، بوسم أو نشر مباشر |
| العمل غير المكتمل | يبقى في فرعه | يبقى في فرعه | يدمج مخفياً خلف Feature Flag |
| ما يحتاجه من CI | مفيد، ويمكن العمل بدونه | ضروري قبل كل دمج | ضروري وسريع على كل commit |
| الإصلاح العاجل | فرع hotfix يدمج في main وdevelop | فرع عادي يدمج وينشر | يصلح على main، وينسخ إلى فرع الإصدار إن وجد |
| يناسب | منتجات تثبت عند العملاء On-Premises بعدة إصدارات مدعومة | تطبيقات ويب وخدمات SaaS بنسخة واحدة | تطبيقات ويب وSaaS، وتطبيقات الجوال مع فرع إصدار قصير لكل مراجعة متجر، ومنتجات On-Premises مع فرع إصدار لكل نسخة مدعومة |
وبهذا تكون الإجابة على سؤال «أي طريقة أفضل؟» أنها تتبع منتجك: إذا كان في الإنتاج نسخة واحدة تنشر كثيراً فاختر Trunk-Based أو GitHub Flow بعمر فرع محدد، وإذا كان تطبيق جوال فالطريقة نفسها مع فرع إصدار قصير لكل إصدار يرسل إلى المتجر، وإذا كنت تدعم عدة إصدارات عند العملاء فما زال لفروع الإصدار مكان، ولكن الإصلاحات تمر بالجذع أولاً دائماً.
مثال عملي: مطوران وفروع قصيرة وFeature Flag وإصدار
الآن سوف نعيد قصة المتجر نفسها بطريقة Trunk-Based، بمطورين Alice وBob ومستودع مشترك، ونقوم بتشغيل كل شيء في Container من Alpine 3.24 مع Git 2.54.0. وحتى يبقى المثال في Git والسكربتات فقط، استخدمنا مستودعاً مجرداً Bare Repository باسم origin.git مكان سيرفر Gitea، وسكربتاً اسمه ci.sh يقوم بما يقوم به ال CI، وفي فريق حقيقي يعمل هذا السكربت في Gitea Actions مع كل Pull Request، والدمج يتم بزر Merge بعد الموافقة ونجاح الفحص.
نقطة البداية على main
الملف checkout.sh يطبع المجموع، ويقرأ ال Flags من flags.env:
#!/bin/sh
# Usage: ./checkout.sh <amount> [coupon]
. ./flags.env
amount="${1:?amount is required}"
echo "total: $amount"والملف flags.env يجعل كل Flag مطفأ إلا إذا فعلته البيئة Environment بمتغير يحمل الاسم نفسه:
# Feature flags: off by default, each environment can turn one on
: "${FEATURE_COUPONS:=off}"والاختبار tests/total.sh، ثم سكربت ال CI الذي يفحص صياغة السكربتات ويشغل كل الاختبارات:
[ "$(./checkout.sh 100)" = "total: 100" ] || { echo "FAIL tests/total.sh"; exit 1; }#!/bin/sh
# The same checks the CI server runs on every push and pull request
set -e
for f in checkout.sh lib/*.sh; do if [ -f "$f" ]; then sh -n "$f"; fi; done
for t in tests/*.sh; do sh "$t"; done
echo "CI: all checks passed"اليوم الأول: فرعان قصيران يدمجان في اليوم نفسه
تبدأ Alice بمحرك الكوبونات، ولا تربطه بصفحة الدفع بعد، فهو كود جديد لا يستدعيه أحد، وهذا هو أسلوب Keystone Interface الذي ذكرناه:
git switch -c feat/coupon-engine
mkdir -p lib
cat > lib/coupons.sh <<'EOF'
# apply_coupon <amount> <code>: prints the amount after the coupon
apply_coupon() {
case "$2" in
SAVE10) awk -v a="$1" 'BEGIN { print a * 0.9 }' ;;
*) echo "$1" ;;
esac
}
EOF
cat > tests/coupons.sh <<'EOF'
. ./lib/coupons.sh
[ "$(apply_coupon 100 SAVE10)" = "90" ] || { echo "FAIL tests/coupons.sh"; exit 1; }
EOF
git add . && git commit -m "Add coupon engine (not used yet)"
./ci.sh
git push -u origin feat/coupon-engineوالمخرج سوف يكون كما يلي:
Switched to a new branch 'feat/coupon-engine'
CI: all checks passed
To /lab/after/origin.git
* [new branch] feat/coupon-engine -> feat/coupon-engine
branch 'feat/coupon-engine' set up to track 'origin/feat/coupon-engine'.وفي الوقت نفسه يفتح Bob الفرع feat/vat من main نفسه ويضيف الضريبة إلى checkout.sh، ويحدث الاختبار ليتوقع total: 115:
total=$(awk -v a="$amount" 'BEGIN { printf "%.0f", a * 1.15 }')
echo "total: $total"يراجع Bob طلب Alice ويوافق عليه فيدمج، وقبل أن يدمج طلبه هو يحدث فرعه على آخر main، لأن حماية الفرع تمنع دمج طلب متأخر عنه:
git fetch
git rebase origin/mainFrom /lab/after/origin
0f794e9..7087665 main -> origin/main
Rebasing (1/1)Successfully rebased and updated refs/heads/feat/vat.لاحظ أن فرع Bob عاش ساعات، وأن ما تغير على main خلالها هو commit واحد من Alice في ملفات أخرى، لذلك مرت إعادة الأساس دون أي تعارض، ثم نجح ال CI ودمج الطلب وحذف الفرع.
اليوم الثاني: ربط الميزة خلف ال Flag ووسم الإصدار
تربط Alice الكوبونات بصفحة الدفع، ولكن خلف ال Flag FEATURE_COUPONS، فيصبح checkout.sh كما يلي:
#!/bin/sh
# Usage: ./checkout.sh <amount> [coupon]
. ./flags.env
amount="${1:?amount is required}"
if [ "$FEATURE_COUPONS" = "on" ] && [ -n "$2" ]; then
. ./lib/coupons.sh
amount=$(apply_coupon "$amount" "$2")
fi
total=$(awk -v a="$amount" 'BEGIN { printf "%.0f", a * 1.15 }')
echo "total: $total"ونقوم الآن بتجربة الحالتين، مرة بالإعداد الافتراضي، ومرة بتفعيل ال Flag كما ستفعل بيئة الاختبار:
./checkout.sh 100 SAVE10
env FEATURE_COUPONS=on ./checkout.sh 100 SAVE10total: 115
total: 103في المخرج أعلاه لاحظ التالي:
- عندما يكون ال Flag مطفأ يتجاهل التطبيق الكوبون تماماً، فهذا الكود يمكن أن يدمج وينشر في الإنتاج اليوم دون أن يتغير شيء على العملاء.
- عندما يكون ال Flag مفعلاً يطبق الخصم ثم الضريبة، وفريق QA يستطيع أن يختبر الميزة على النسخة نفسها التي سوف تصدر، لا على فرع منفصل.
- الرقم
103بدل103.50علامة على خطأ في تقريب الضريبة لم ينتبه له أحد بعد، وسوف يظهر في اليوم الثالث.
بعد دمج الطلب يضع الفريق وسم الإصدار على main، ولأن التطبيق في مثالنا يرسل إلى متجر يراجع كل إصدار، يفتحون فرع إصدار قصيراً من الوسم نفسه:
git tag -a v1.1.0 -m "Release 1.1.0"
git push origin v1.1.0
git switch -c release/1.1 v1.1.0
git push -u origin release/1.1Switched to a new branch 'release/1.1'اليوم الثالث: خطأ في الإصدار، والإصلاح يمر بالجذع أولاً
يستمر العمل على main، فتضيف Alice كوبوناً ثانياً SAVE20 في فرع قصير ويدمج، ثم يبلغ أحد المستخدمين أن مبلغاً مثل 19.99 يظهر بعد الضريبة 23 بدل 22.99:
./checkout.sh 19.99total: 23يفتح Bob فرع الإصلاح من main، لا من فرع الإصدار، ويغير %.0f إلى %.2f، ويضيف اختباراً للحالة التي كشفت الخطأ:
git switch -c fix/vat-rounding
sed -i "s/%.0f/%.2f/" checkout.sh
sed -i 's/total: 115"/total: 115.00"/' tests/total.sh
echo '[ "$(./checkout.sh 19.99)" = "total: 22.99" ] || { echo "FAIL tests/total.sh rounding"; exit 1; }' >> tests/total.sh
git commit -am "Fix VAT rounding: keep two decimals"
./ci.shSwitched to a new branch 'fix/vat-rounding'
CI: all checks passedوقبل أن ننسخ الإصلاح، لنكسر الأمر عمداً لنفهم قيمة الاختبار: إذا أضفنا اختبار التقريب وحده إلى نسخة منفصلة من المستودع على الوسم v1.1.0 وشغلنا ال CI عليها فإنه يفشل كما يلي، وهذا ما يمنع إصدار v1.1.1 لو نسي أحد نسخ الإصلاح:
git switch --detach v1.1.0
echo '[ "$(./checkout.sh 19.99)" = "total: 22.99" ] || { echo "FAIL tests/total.sh rounding"; exit 1; }' >> tests/total.sh
./ci.sh; echo "exit=$?"FAIL tests/total.sh rounding
exit=1بعد مراجعة الطلب ودمجه في main، ينسخ Bob ال commit نفسه إلى فرع الإصدار بالخيار -x، ثم يشغل ال CI ويضع الوسم:
git log --oneline -1 32eaa41
git switch release/1.1
git cherry-pick -x 32eaa41
./ci.sh
./checkout.sh 19.99
git tag -a v1.1.1 -m "Release 1.1.1"
git push origin release/1.1 v1.1.132eaa41 Fix VAT rounding: keep two decimals
branch 'release/1.1' set up to track 'origin/release/1.1'.
Switched to a new branch 'release/1.1'
[release/1.1 995a7ec] Fix VAT rounding: keep two decimals
Date: Mon Oct 5 22:19:38 2026 +0000
2 files changed, 3 insertions(+), 2 deletions(-)
CI: all checks passed
total: 22.99ورسالة ال commit على فرع الإصدار، بالأمر git log -1 --format=%B، تحمل مصدرها:
Fix VAT rounding: keep two decimals
(cherry picked from commit 32eaa417d2071f096934e9b8663450ac7be86a38)لاحظ أن فرع الإصدار أخذ الإصلاح وحده، ولم يأخذ الكوبون SAVE20 الذي دمج على main بعد الإصدار، وهذا هو سبب وجود فرع الإصدار أصلاً، وأن الإصلاح مكتوب مرة واحدة على main، فالإصدار القادم من main يحمله تلقائياً دون أي دمج مزدوج.
شكل التاريخ بعد ثلاثة أيام
والآن نقارن بين الشكلين، فهذا هو تاريخ المستودع بالأمر git log --graph --oneline --decorate --all:
* 995a7ec (HEAD -> release/1.1, tag: v1.1.1, origin/release/1.1) Fix VAT rounding: keep two decimals
| * e305f5f (origin/main, origin/HEAD, main) Merge pull request 'Fix VAT rounding' (fix/vat-rounding)
| |\
| | * 32eaa41 Fix VAT rounding: keep two decimals
| |/
| * 46d8d97 Merge pull request 'Add SAVE20' (feat/coupon-codes)
|/|
| * f102c34 Add SAVE20 coupon code
|/
* 3a480af (tag: v1.1.0) Merge pull request 'Coupons in checkout behind flag' (feat/coupon-ui)
|\
| * 040e238 Use coupons in checkout behind FEATURE_COUPONS
|/
* e55ebe8 Merge pull request 'Add VAT' (feat/vat)
|\
| * d076121 Add 15% VAT to the total
|/
* 7087665 Merge pull request 'Add coupon engine' (feat/coupon-engine)
|\
| * e05e74d Add coupon engine (not used yet)
|/
* 0f794e9 (tag: v1.0.0) Initial checkout with CI and flagsوالفروع الموجودة على السيرفر بعد كل ذلك، بالأمر git branch -r:
origin/HEAD -> origin/main
origin/main
origin/release/1.1في المخرج أعلاه لاحظ التالي:
- كل فرع ميزة يخرج من
mainويعود إليه بعد commit أو اثنين، فلا توجد خطوط متوازية تمتد أسابيع كما في التاريخ الأول. - على السيرفر فرعان نشطان فقط، وهذا داخل حد الفروع الثلاثة الذي تذكره DORA، لأن كل فرع ميزة حذف بعد دمجه.
- كل تغيير جديد على
mainمر بال CI، وأي commit منها يصلح أن يوسم ويصدر. - بعد أن يكتمل إطلاق الكوبونات ويفعل ال Flag للجميع، تكون الخطوة التالية طلب دمج يحذف ال Flag والمسار القديم.
وفي آخر التجربة فعلنا ال Flag على main مع الكوبون الجديد للتأكد من أن الميزتين والإصلاح يعملون معاً:
env FEATURE_COUPONS=on ./checkout.sh 100 SAVE20total: 92.00وأين يختبر فريق QA؟
في هذه الطريقة يختبر فريق QA ما على main، لأنه هو ما سوف يصدر، فكل دمج ينشر تلقائياً إلى بيئة dev أو staging، والميزة غير المكتملة تفعل بال Flag في تلك البيئة وحدها. وإذا أراد الفريق أن يختبر طلب دمج بعينه قبل دمجه فالحل بيئة معاينة Preview Environment تنشأ لكل Pull Request ثم تحذف، ونقل الإصدار نفسه بين dev وstaging وprod يتم بطريقة GitOps، وكل ذلك مشروح بالتفصيل في البيئات ومعاينة الميزات وGitOps للفرق، فلا تقم بإنشاء فرع دائم لكل بيئة مثل staging وqa، والسبب أن كل فرع من هذه الفروع يعيد مشكلة الفروع الطويلة نفسها.
كيف تنقل فريقك من Git Flow خطوة بخطوة
لا تقم بتغيير طريقة العمل كلها في يوم واحد، فالفريق الذي ليس لديه CI موثوق سوف يكسر main في الأسبوع الأول ويعود إلى الطريقة القديمة وهو مقتنع أن الجديدة لا تعمل. والترتيب التالي يبني كل خطوة على التي قبلها:
- قس الوضع الحالي: كم فرعاً نشطاً في المستودع، وكم يوماً يعيش فرع الميزة في المتوسط، وكم ساعة يستغرق دمج الإصدار، فهذه الأرقام من مستودعك أنت هي ما سوف تقارن به بعد شهر.
- اجعل ال CI سريعاً وموثوقاً: كل Pull Request يشغل الاختبارات والبناء، والاختبارات المتقلبة Flaky Tests تصلح أو تحذف، لأن الفريق الذي يتجاهل الفشل الأحمر مرة سوف يتجاهله دائماً.
- فعل حماية الفرع: لا يوجد دفع مباشر، ومراجعة واحدة، وفحص الحالة إلزامي، كما في قسم حماية الفرع أعلاه.
- حدد عمر الفرع: اتفق مع الفريق على أن فرع الميزة لا يتجاوز يومين، والعمل الأكبر يقسم إلى طلبات دمج صغيرة، وابدأ بالميزات الجديدة ولا تجبر الفروع القديمة المفتوحة على هذه القاعدة.
- أضف ال Feature Flags: ابدأ بمتغيرات بيئة كما في المثال، وضع قاعدة أن كل ميزة لن تكتمل في يومين تبدأ خلف Flag مطفأ.
- أغلق فرع develop: بعد أن تدمج الفروع القديمة، ادمج
developفيmainمرة أخيرة، واجعلmainهو الفرع الافتراضي Default Branch في Gitea وهدف كل طلب دمج، ثم احذفdevelop. - غير طريقة الإصدار والإصلاح: الإصدار وسم على
main، وفرع الإصدار يفتح فقط عند الحاجة، والإصلاح العاجل يكتب علىmainوينسخ بال cherry-pick، فلا توجد فروع hotfix تدمج مرتين.
الجانب الثقافي: الأداة سهلة، والعادة هي الصعبة
- وقت متفق عليه للمراجعة Review SLA: طلب الدمج الصغير الذي ينتظر يومين يجعل صاحبه يبدأ عملاً جديداً فوقه، فيكبر الفرع، لذلك اتفقوا على مدة قصيرة ومكتوبة للرد على كل طلب، مثل نصف يوم عمل، وعلى أن مراجعة طلبات الزملاء تسبق البدء في عمل جديد.
- البرمجة الثنائية Pair Programming: عندما يكتب مطوران الكود معاً فالمراجعة تحدث أثناء الكتابة، وهي الطريقة الأسرع للمراجعة المتزامنة التي توصي بها DORA.
- البناء الأحمر مسؤولية الجميع: إذا فشل ال CI على
mainفإصلاحه يسبق أي عمل آخر، ومن دمج التغيير الذي كسره يتولى الأمر، والحل الأسرع غالباً هو التراجع عن ال commit بالأمرgit revertثم إصلاحه بهدوء في طلب جديد، والسبب أنmainالمكسور يوقف الفريق كله عن الدمج. - الطلب الصغير ليس عملاً ناقصاً: بعض المطورين يرون أن رفع جزء من الميزة يعني أنها لم تكتمل، فوضح للفريق أن الجزء المدمج خلف Flag هو تقدم حقيقي يراه الجميع ويختبرونه.
أخطاء شائعة
main رهاناً على أن لا شيء انكسر.- Flags لا تحذف أبداً Flag Debt: كل Flag يضاعف المسارات التي يجب أن تختبر، ويذكر trunkbaseddevelopment.com أن ال Flags تنسى في الكود عندما ينتقل الفريق إلى أعمال جديدة، ويقترح أن يكتب مع كل Flag موعد لمراجعته وحذفه، ويصف Pete Hodgson الفرق المتمرسة بأنها تعامل ال Feature Toggles كمخزون له تكلفة وتحاول أن تبقيه في أقل حد. لذلك افتح مهمة حذف ال Flag في اليوم الذي تنشئه فيه.
- طلبات دمج ضخمة: فرع يعيش يوماً واحداً ولكنه يغير أربعين ملفاً هو فرع طويل في حجمه، والمراجعة عليه شكلية، فقسم العمل حسب ما يمكن مراجعته في دقائق، لا حسب ما يمكن كتابته في يوم.
- الدفع المباشر دون مراجعة في الفرق الخاضعة للتنظيم: الدمج المباشر في الجذع مناسب لفريق صغير جداً، أما الفرق التي تخضع لمتطلبات تدقيق Audit أو فصل الصلاحيات Separation of Duties فتحتاج دليلاً على أن شخصاً آخر راجع كل تغيير، لذلك استخدم الفروع القصيرة مع الموافقة الإلزامية وحماية الفرع حتى للمدراء، فالسجل في Gitea يصبح هو الدليل.
- فرع إصدار يتحول إلى develop جديد: إذا بدأ الفريق يكتب ميزات على
release/1.1أو يبقيه مفتوحاً أشهراً دون حاجة فقد عاد إلى المشكلة الأولى، ففرع الإصدار يأخذ إصلاحات منسوخة منmainفقط. - اختبار مسار واحد لل Flag: إذا شغل ال CI الاختبارات وال Flag مطفأ فقط، فالمسار الجديد يصل إلى الإنتاج دون اختبار، ويوصي الموقع نفسه بأن يغطي ال CI الحالات المتوقعة من ال Flags، أي ال Flag مطفأ ومفعل على الأقل.
الخلاصة
وصلنا لنهاية الموضوع، وأهم ما فيه:
- مشكلة الفروع الطويلة ليست في Git، وإنما في الكود الذي يبقى غير مدمج أسابيع، فتظهر التعارضات وأخطاء التكامل في يوم الإصدار، ويختبر QA فرعاً لن يصدر، وتطبق الإصلاحات مرتين.
- Trunk-Based Development يعني أن كل مطور يدمج عمله في
mainمرة في اليوم على الأقل، عبر فروع تعيش ساعات أو يومين على الأكثر، وmainصالح للإصدار دائماً، وتربط أبحاث DORA الأداء الأعلى بثلاثة فروع نشطة أو أقل ودمج يومي وغياب تجميد الكود. - الطريقة تعمل بشروطها: CI سريع على كل طلب دمج، وحماية الفرع، وطلبات صغيرة ومراجعة سريعة، وFeature Flags تخفي العمل غير المكتمل، وBranch by Abstraction للتغييرات الكبيرة.
- الإصدار وسم على
main، وفرع الإصدار القصير يفتح فقط لتطبيقات الجوال والمنتجات متعددة الإصدارات، والإصلاح يكتب علىmainأولاً ثم ينسخ بالأمرgit cherry-pick -x. - Git Flow ما زال مناسباً لبرمجيات تدعم عدة إصدارات عند العملاء، وكاتبه نفسه يوصي فرق التسليم المستمر بطريقة أبسط، وGitHub Flow يصبح Trunk-Based عندما تحدد عمر الفرع وتضيف ال Feature Flags.
- انتقل تدريجياً: CI وحماية الفرع أولاً، ثم حد لعمر الفرع وال Feature Flags، ثم إغلاق
develop، واحذف كل Flag بعد اكتمال إطلاقه.
دليلمن الفرع إلى الإنتاج: البيئات وGitOps للفرقكيف يختبر فريقك كل ميزة وحدها ويعرضها على العميل دون أن تصل ميزة غير جاهزة إلى الإنتاج.
دليلGit في العمل اليومي: الدمج والتراجع دون أن تخسر عملكمتى تستخدم Merge ومتى تستخدم Rebase، وكيف تنقل إصلاحاً إلى فرع الإصدار أو تسترجع عملاً ظننت أنه ضاع.
دليلإدارة النسخ وGit للمبتدئين: لماذا نحتاجها وكيف تعمللماذا تضيع الملفات بأسماء final_v2 ويمسح الزملاء عمل بعضهم، وكيف يحل Git ذلك على مشروع حقيقي من أول commit.
دليلGitea Actions: تشغيل CI/CD وبناء Docker Images على سيرفركاجعل سيرفرك يختبر الكود ويبني Docker Images تلقائياً مع كل push، دون أن يخرج كودك أو أسرارك إلى خدمة CI خارجية.
دليلقبل أن تنشر تطبيقك على خادمك: قائمة تحقق للمطورالأخطاء التي لا تظهر إلا بعد نشر تطبيقك في Docker خلف Reverse Proxy، وكيف تعالج كل واحد منها قبل أن يصل إلى المستخدمين.سجل التحديثات
- أكتوبر 2026: كتابة المقال، وتنفيذ المثالين (الفروع الطويلة وTrunk-Based بمطورين وFeature Flag وفرع إصدار وCherry-Pick) في Container من Alpine 3.24.2 مع Git 2.54.0.