بعد أن تتعلم أن تنشئ Commit وفرعاً Branch وتدفع إلى السيرفر، يبدأ العمل الحقيقي مع الفريق، فتجد نفسك أمام أسئلة لا يجيب عنها الدليل الأول: زميلك دفع قبلك فرفض السيرفر الدفع، وفرعك تأخر عن main أسبوعاً، ومنصة Gitea تسألك عند الدمج هل تريد Merge أم Rebase أم Squash، والعميل يريد إصلاحاً واحداً في الإصدار الذي يعمل عنده الآن دون الميزات التي لم تكتمل، وأحدهم نفذ git reset --hard فاختفى عمل يوم كامل، وآخر حذف ملف .env بعد أن دفعه وظن أن المشكلة انتهت. وكل هذه المواقف لها أوامر في Git صممت لها بالتحديد، ولكن كل أمر منها له قاعدة أمان إذا تجاوزتها خسرت عملك أو عمل زملائك.
وقد شرحنا المستودع Repository وال Commit والفروع ومنطقة التجهيز Staging Area وطريقة الدفع والسحب في دليل إدارة النسخ وGit للمبتدئين، فلن نعيدها هنا، وهذا المقال يبدأ من حيث انتهى ذلك الدليل، وفي كل أمر سوف نسير على الترتيب نفسه: المشكلة التي يحلها، ثم كيف يعمل مع رسم لشكل التاريخ قبله وبعده، ثم مخرجات حقيقية من مستودع نفذنا عليه الأمثلة، ثم متى تستخدمه ومتى لا تستخدمه، ثم قاعدة الأمان. وسوف نناقش في هذا المقال ما يلي:
- الدمج Merge بنوعيه: Fast-forward والدمج الثلاثي Three-way، والخيار
--no-ff. - إعادة التأسيس Rebase، والقاعدة الذهبية، والأمر
git pull --rebase، وإعادة التأسيس التفاعلية Interactive Rebase لترتيب التزاماتك قبل طلب الدمج. - مقارنة صادقة بين Merge وRebase، والسياسات التي تتبعها الفرق، ثم الدمج مع الضغط Squash Merge.
- الانتقاء Cherry-pick لنقل إصلاح إلى فرع إصدار، والتراجع بالأوامر revert وreset وrestore، وشبكة الأمان reflog.
- الحفظ المؤقت Stash، والوسوم Tags وترقيم الإصدارات Semantic Versioning.
- التعارضات Conflicts بعمق أكثر: mergetool وrerere وكيف تتجنبها.
- العمل مع الفريق: طلبات الدمج Pull Requests وحماية الفروع وفحوص CI الإلزامية، والدفع بالقوة الآمن
--force-with-lease. - مقارنة قصيرة بين استراتيجيات التفرع Git Flow وGitHub Flow وTrunk-Based.
- البحث عن الالتزام الذي كسر البرنامج بالأمر
git bisect، ثم الأخطاء الشائعة وجدول مختصر للأوامر.
المستودع الذي سوف نجرب عليه
كل المخرجات في هذا المقال حقيقية، ونفذناها على Git 2.48.1 في مستودعات صغيرة لمتجر وهمي فيه ملف app.py بدوال مثل price وdiscount، وكل مثال يبدأ بمستودع جديد حتى لا تختلط الأمثلة، ولذلك تختلف أرقام ال Commits من مثال إلى آخر. ولكي ترى شكل التاريخ في أي لحظة، فالأمر الذي سوف يتكرر كثيراً هو:
git log --oneline --graph --allحيث --oneline يعرض كل Commit في سطر واحد برقمه المختصر ورسالته، و--graph يرسم الخطوط بين ال Commits، و--all يعرض كل الفروع وليس الفرع الحالي فقط. أما في الرسوم فسوف نستخدم الحروف A وB وC بدلاً من الأرقام الحقيقية حتى يسهل تتبعها، والزمن فيها يجري من اليسار إلى اليمين كما في أغلب أدوات Git الرسومية.
الدمج Merge: جمع عمل فرعين
المشكلة التي يحلها الدمج بسيطة، فأنت فتحت فرعاً اسمه feature/discount وأضفت فيه دالة الخصم وانتهيت منها، والآن تريد أن تصل هذه التعديلات إلى main. والدمج له شكلان، وGit يختار بينهما تلقائياً بحسب ما حدث في main أثناء عملك على الفرع.
الدمج السريع Fast-forward: عندما لا يتحرك main
إذا لم يضف أحد أي Commit إلى main منذ أن فتحت فرعك، فإن فرعك هو main نفسه مع التزامات إضافية في آخره، ولذلك لا يحتاج Git أن ينشئ شيئاً جديداً، وإنما يحرك المؤشر main إلى الأمام حتى يصل إلى آخر Commit في فرعك، والمثال التالي يبين ذلك:
git switch main
git merge feature/discount
git log --oneline --graphوالمخرج سوف يكون كما يلي:
Updating 9b34bb8..d5a7086
Fast-forward
README.md | 2 ++
app.py | 3 +++
2 files changed, 5 insertions(+)
* d5a7086 Document discount
* 3b93256 Add discount
* 9b34bb8 Add README
* 944030e Add price functionفي المخرج أعلاه لاحظ التالي:
- السطر
Updating 9b34bb8..d5a7086يعني أن main كان يشير إلى 9b34bb8 وأصبح يشير إلى d5a7086، وهو آخر Commit في الفرع. - الكلمة
Fast-forwardتعني أنه لم ينشأ أي Commit جديد، والتاريخ خط مستقيم لا يظهر فيه أن هذه التعديلات كانت في فرع منفصل.
الدمج الثلاثي Three-way Merge: عندما يتقدم الفرعان
أما إذا دفع زميلك إلى main التزاماً جديداً أثناء عملك، وفي مثالنا التزاماً يضيف ملف الترخيص LICENSE، فإن الفرعين تباعدا، ولم يعد تحريك المؤشر ممكناً لأن ذلك سوف يحذف التزام زميلك من main. وهنا يقارن Git ثلاث نسخ، ولهذا سمي الدمج ثلاثياً: النسخة في آخر main، والنسخة في آخر الفرع، والنسخة في النقطة المشتركة Common Ancestor التي انفصل عندها الفرعان، فيأخذ من كل طرف ما غيره، ثم ينشئ التزام دمج Merge Commit له أبوان:
git log --oneline --graph --all
git merge -m "Merge branch 'feature/discount'" feature/discount
git log --oneline --graphوالمخرج سوف يكون كما يلي:
* bc01bf8 Add license
| * d5a7086 Document discount
| * 3b93256 Add discount
|/
* 9b34bb8 Add README
* 944030e Add price function
Merge made by the 'ort' strategy.
README.md | 2 ++
app.py | 3 +++
2 files changed, 5 insertions(+)
* 04b6c2a Merge branch 'feature/discount'
|\
| * d5a7086 Document discount
| * 3b93256 Add discount
* | bc01bf8 Add license
|/
* 9b34bb8 Add README
* 944030e Add price functionفي المخرج أعلاه لاحظ التالي:
- الرسم الأول يبين التباعد: الفرع يبدأ من 9b34bb8، وmain تقدم بعده بالتزام bc01bf8.
- العبارة
the 'ort' strategyهي اسم خوارزمية الدمج، وهي الخوارزمية الافتراضية عند دمج فرع واحد منذ Git 2.34، وقد حلت محل recursive. - الالتزام 04b6c2a هو التزام الدمج، والخطان اللذان يلتقيان عنده يعنيان أن له أبوين، والتاريخ يحفظ أن التزامي الخصم كانا في فرع مستقل ثم اندمجا في هذه النقطة بالتحديد.
الخيار --no-ff: التزام دمج حتى لو كان الدمج السريع ممكناً
وقد يتساءل البعض: إذا كان الدمج السريع أبسط، فلماذا يطلب أحد التزام دمج لا يحتاجه؟ والإجابة أن الدمج السريع يمحو أثر الفرع، فبعد شهر لن تعرف من التاريخ أن هذين الالتزامين كانا ميزة واحدة دخلت معاً، ولن تستطيع أن تتراجع عن الميزة كلها بأمر واحد. لذلك يجبر الخيار --no-ff الأداة على إنشاء التزام دمج دائماً، وهذا هو الأسلوب الذي تتبعه المنصات مثل GitHub في خيار Create a merge commit، وقد نفذناه على المثال الأول نفسه قبل أن يتحرك main:
git merge --no-ff -m "Merge branch 'feature/discount'" feature/discount
git log --oneline --graphوالمخرج سوف يكون كما يلي:
Merge made by the 'ort' strategy.
README.md | 2 ++
app.py | 3 +++
2 files changed, 5 insertions(+)
* 52b99b8 Merge branch 'feature/discount'
|\
| * d5a7086 Document discount
| * 3b93256 Add discount
|/
* 9b34bb8 Add README
* 944030e Add price functionوالصورة التالية تبين الفرق بين الشكلين:

ومتى تستخدم كل شكل؟ الدمج السريع مناسب عندما تحدث فرعك المحلي من السيرفر، أو عندما يكون الفرع التزاماً واحداً صغيراً لا يستحق أثراً في التاريخ، والخيار --no-ff مناسب عندما تدمج ميزة كاملة في main وتريد أن تبقى ظاهرة كوحدة واحدة. وإذا أردت العكس، أي أن يرفض Git الدمج إذا لم يكن سريعاً بدلاً من أن ينشئ التزام دمج دون أن تنتبه، فاستخدم git merge --ff-only، وهذا مفيد في السكربتات وفي تحديث الفرع المحلي.
وقاعدة الأمان في الدمج أنه لا يغير أي التزام موجود، فهو يضيف فقط، لذلك فالدمج آمن دائماً على الفروع المشتركة، وإذا ندمت عليه قبل الدفع فتستطيع أن تتراجع عنه كما سيأتي في قسم التراجع.
إعادة التأسيس Rebase: نقل عملك فوق آخر نقطة في main
لنرجع إلى حالة التباعد نفسها: أنت في الفرع feature/discount ولديك التزامان، وmain تقدم بالتزام الترخيص. والدمج الثلاثي يحل المشكلة، ولكنه يضيف التزام دمج، وإذا كان الفريق يعمل على عشرة فروع في الأسبوع، وكل شخص يدمج main في فرعه كل يوم ليبقى محدثاً، فسوف يمتلئ التاريخ بالتزامات من نوع «Merge branch main into feature» لا تحمل أي معلومة، ويصبح الرسم شبكة من الخطوط يصعب أن تقرأها.
وإعادة التأسيس Rebase حل آخر: بدلاً من أن تربط التاريخين بالتزام دمج، تأخذ التزاماتك وتعيد تطبيقها واحداً بعد الآخر فوق آخر نقطة في main، كأنك فتحت الفرع اليوم وليس قبل أسبوع. والمثال التالي يبين ذلك:
git switch feature/discount
git log --oneline --graph --all
git rebase main
git log --oneline --graph --allوالمخرج سوف يكون كما يلي:
* 10300ca Add license
| * cfe90b2 Document discount
| * 46e4452 Add discount
|/
* d32f45b Add README
* 8567728 Add price function
Successfully rebased and updated refs/heads/feature/discount.
* 0e06f59 Document discount
* 4438dfe Add discount
* 10300ca Add license
* d32f45b Add README
* 8567728 Add price functionفي المخرج أعلاه لاحظ التالي:
- بعد إعادة التأسيس أصبح التاريخ خطاً واحداً، والتزاما الخصم يأتيان بعد التزام الترخيص مباشرة.
- الأهم أن أرقام الالتزامين تغيرت: 46e4452 أصبح 4438dfe، وcfe90b2 أصبح 0e06f59. والسبب أن رقم ال Commit هاش يحسب من محتواه ومن رقم الالتزام الأب، فلما تغير الأب تغير الرقم، أي أن Git لم ينقل الالتزامات القديمة، وإنما أنشأ التزامات جديدة بالتغييرات نفسها، وترك القديمة دون فرع يشير إليها.
والصورة التالية تبين ما حدث خطوة بخطوة:

وبعد إعادة التأسيس يصبح دمج الفرع في main دمجاً سريعاً، لأن الفرع أصبح main مع التزامات إضافية في آخره:
git switch main
git merge feature/discountوالمخرج سوف يكون كما يلي:
Updating 10300ca..0e06f59
Fast-forward
README.md | 2 ++
app.py | 3 +++
2 files changed, 5 insertions(+)القاعدة الذهبية: لا تعد تأسيس التزامات سحبها غيرك
بما أن إعادة التأسيس تنشئ التزامات جديدة وتترك القديمة، فماذا يحدث إذا كان زميلك قد سحب الفرع القديم وبنى عليه عمله؟ سوف يجد عنده التزامات لم تعد موجودة على السيرفر، ونسخاً منها بأرقام أخرى، وعند السحب سوف يحاول Git أن يدمج النسختين، فتظهر التغييرات نفسها مرتين، وتظهر تعارضات في كود لم يغيره أحد. ولذلك يضع كتاب Pro Git القاعدة بوضوح: لا تقم بإعادة تأسيس التزامات موجودة خارج مستودعك وقد يكون غيرك بنى عليها عمله.
الأمر git pull --rebase: سحب دون التزامات دمج
في دليل المبتدئين رأينا السيرفر يرفض الدفع لأن زميلك دفع قبلك، والحل هناك كان git pull بالدمج، فينشأ التزام دمج من نوع «Merge branch 'main' of …» في كل مرة يحدث فيها ذلك. وهذه الحالة بالتحديد هي أفضل استخدام لإعادة التأسيس، لأن التزامك المحلي لم يره أحد بعد، والمثال التالي يبين ذلك بمستودعين لشخصين يدفعان إلى سيرفر واحد:
git push origin main
git pull origin main
git pull --rebase origin main
git log --oneline --graph
git push origin mainوالمخرج سوف يكون كما يلي:
To ../remote.git
! [rejected] main -> main (fetch first)
error: failed to push some refs to '../remote.git'
hint: Updates were rejected because the remote contains work that you do not
hint: have locally. This is usually caused by another repository pushing to
hint: the same ref. If you want to integrate the remote changes, use
hint: 'git pull' before pushing again.
hint: See the 'Note about fast-forwards' in 'git push --help' for details.
From ../remote
* branch main -> FETCH_HEAD
3c89335..b1eecf7 main -> origin/main
hint: You have divergent branches and need to specify how to reconcile them.
hint: You can do so by running one of the following commands sometime before
hint: your next pull:
hint:
hint: git config pull.rebase false # merge
hint: git config pull.rebase true # rebase
hint: git config pull.ff only # fast-forward only
hint:
hint: You can replace "git config" with "git config --global" to set a default
hint: preference for all repositories. You can also pass --rebase, --no-rebase,
hint: or --ff-only on the command line to override the configured default per
hint: invocation.
fatal: Need to specify how to reconcile divergent branches.
From ../remote
* branch main -> FETCH_HEAD
Successfully rebased and updated refs/heads/main.
* 1488c28 Add license
* b1eecf7 Add README
* 3c89335 Add price function
To ../remote.git
b1eecf7..1488c28 main -> mainفي المخرج أعلاه لاحظ التالي:
- الأمر
git pullدون خيار توقف ولم يفعل شيئاً، والسبب أن الإصدارات الحديثة من Git تطلب منك أن تختار الطريقة عندما يتباعد الفرعان، والرسالة نفسها تقترح الإعداد الدائم. - مع
--rebaseجلب Git التزام زميلك b1eecf7، ثم أعاد تطبيق التزامك فوقه، فصار رقمه 1488c28، وقبل السيرفر الدفع لأنه أصبح دمجاً سريعاً من جهته.
وإذا أردت أن يكون هذا هو السلوك الدائم فنفذ git config --global pull.rebase true مرة واحدة، كما يذكر توثيق git pull، وإذا ظهر تعارض أثناء إعادة التأسيس فإن Git يتوقف عند الالتزام الذي فيه التعارض، فتحله كما شرحنا في قسم التعارضات ثم تنفذ git rebase --continue، أو تلغي العملية كلها وترجع إلى ما كنت عليه بالأمر git rebase --abort.
إعادة التأسيس التفاعلية: رتب التزاماتك قبل أن يراها أحد
لنكن صادقين، فالتاريخ الحقيقي لأي فرع لا يبدو مرتباً، فأنت تكتب الميزة، ثم التزاماً اسمه «WIP debug» فيه ملف تجربة نسيته، ثم «fix typo» لأنك أخطأت في الالتزام السابق، ثم «docs». وهذه الالتزامات مفيدة لك أثناء العمل، ولكنها لا تفيد زميلك الذي يراجع طلب الدمج، ولا من يقرأ التاريخ بعد سنة. وإعادة التأسيس التفاعلية git rebase -i تفتح لك قائمة بالتزامات الفرع، فتقرر لكل التزام ماذا تفعل به قبل أن يعاد تطبيقه. وهذا هو الفرع في مثالنا:
git log --onelineوالمخرج سوف يكون كما يلي:
2748bd4 Make the discount rate configurable
3247062 docs
f5a3739 fix typo
681d3a6 WIP debug
6174f96 Add discount
1412288 Add price functionوعند تنفيذ git rebase -i main يفتح Git المحرر بملف يسمى قائمة المهام Todo List، ولكي نعرضه هنا كما هو جعلنا المحرر هو الأمر cat عبر المتغير GIT_SEQUENCE_EDITOR، فطبع القائمة ثم نفذها دون تغيير:
GIT_SEQUENCE_EDITOR=cat git rebase -i mainوالمخرج سوف يكون كما يلي:
pick 6174f96 Add discount
pick 681d3a6 WIP debug
pick f5a3739 fix typo
pick 3247062 docs
pick 2748bd4 Make the discount rate configurable
# Rebase 1412288..2748bd4 onto 1412288 (5 commands)
#
# Commands:
# p, pick <commit> = use commit
# r, reword <commit> = use commit, but edit the commit message
# e, edit <commit> = use commit, but stop for amending
# s, squash <commit> = use commit, but meld into previous commit
# f, fixup [-C | -c] <commit> = like "squash" but keep only the previous
# commit's log message, unless -C is used, in which case
# keep only this commit's message; -c is same as -C but
# opens the editor
# x, exec <command> = run command (the rest of the line) using shell
# b, break = stop here (continue rebase later with 'git rebase --continue')
# d, drop <commit> = remove commit
# l, label <label> = label current HEAD with a name
# t, reset <label> = reset HEAD to a label
# m, merge [-C <commit> | -c <commit>] <label> [# <oneline>]
# create a merge commit using the original merge commit's
# message (or the oneline, if no original merge commit was
# specified); use -c <commit> to reword the commit message
# u, update-ref <ref> = track a placeholder for the <ref> to be updated
# to this position in the new commits. The <ref> is
# updated at the end of the rebase
#
# These lines can be re-ordered; they are executed from top to bottom.
#
# If you remove a line here THAT COMMIT WILL BE LOST.
#
# However, if you remove everything, the rebase will be aborted.
#
Successfully rebased and updated refs/heads/feature/discount.لاحظ أن القائمة مرتبة من الأقدم إلى الأحدث، أي عكس git log، وأن كل سطر يبدأ بأمر، والأوامر التي سوف تستخدمها في أغلب الأحيان هي:
- pick: أبق الالتزام كما هو.
- reword: أبق الالتزام وغير رسالته فقط.
- squash: ادمج الالتزام في الالتزام الذي قبله، واجمع الرسالتين في رسالة واحدة تحررها.
- fixup: مثل squash، ولكنه يحذف رسالة هذا الالتزام ويبقي رسالة الالتزام السابق، وهو المناسب لالتزامات «fix typo».
- drop: احذف الالتزام وتغييراته نهائياً.
- تغيير الترتيب: انقل السطر إلى مكان آخر في القائمة، فيعاد تطبيق الالتزامات بالترتيب الجديد.
والآن نكتب القائمة التي نريدها: نبقي «Add discount»، ونلحق به «fix typo» بالأمر fixup لأنه إصلاح له، ثم ننقل «Make the discount rate configurable» إلى مكانه بعدهما وندمجه بالأمر squash، ونحذف التزام التجربة بالأمر drop، ونغير رسالة «docs» إلى رسالة مفهومة بالأمر reword. وفي العمل اليومي سوف تعدل هذه الأسطر في المحرر بنفسك، أما هنا فقد كتبناها بسكربت صغير جعلناه المحرر، ومعه سكربت آخر يكتب الرسائل الجديدة عبر GIT_EDITOR، وهذه هي القائمة التي نفذها Git:
pick 6174f96 Add discount
fixup f5a3739 fix typo
squash 2748bd4 Make the discount rate configurable
drop 681d3a6 WIP debug
reword 3247062 docsثم نفذنا إعادة التأسيس:
GIT_SEQUENCE_EDITOR="bash ../todo.sh" GIT_EDITOR="bash ../msg.sh" git rebase -i main
git log --oneline
git show --stat HEAD~1والمخرج سوف يكون كما يلي:
[detached HEAD 543ee79] Add a configurable discount
Date: Tue Oct 6 01:08:26 2026 +0300
1 file changed, 3 insertions(+)
[detached HEAD 553542c] Document the discount in README
Date: Tue Oct 6 01:08:39 2026 +0300
1 file changed, 3 insertions(+)
create mode 100644 README.md
Successfully rebased and updated refs/heads/feature/discount.
553542c Document the discount in README
543ee79 Add a configurable discount
1412288 Add price function
commit 543ee79f6710f17cf14f4ecd65ac8ef249fef593
Author: ArabRoot Team <[email protected]>
Date: Tue Oct 6 01:08:26 2026 +0300
Add a configurable discount
app.py | 3 +++
1 file changed, 3 insertions(+)في المخرج أعلاه لاحظ التالي:
- خمسة التزامات أصبحت التزامين، كل واحد منهما يحمل فكرة كاملة برسالة واضحة، وهذا ما سوف يراه المراجع في طلب الدمج.
- الالتزام 543ee79 يجمع ثلاثة التزامات: الدالة الأولى وإصلاحها والمعامل الجديد، والأمر
git show --statيبين أنه يغيرapp.pyفقط، أي أن ملف التجربةdebug.pyاختفى مع التزامه. - عبارة
detached HEADطبيعية أثناء إعادة التأسيس، لأن Git يبني الالتزامات الجديدة أولاً ثم ينقل الفرع إليها في النهاية.
git commit --fixup=<hash>، ثم نفذ لاحقاً git rebase -i --autosquash main، فيضع Git كل التزام fixup تحت الالتزام الذي يصلحه تلقائياً، كما في توثيق git rebase.وقاعدة الأمان هنا هي القاعدة الذهبية نفسها، فإعادة التأسيس التفاعلية تعيد كتابة الالتزامات، لذلك نفذها على فرعك قبل أن تفتح طلب الدمج، وإذا كنت قد دفعت الفرع من قبل فسوف تحتاج إلى الدفع بالقوة، وهذا مقبول لفرعك الشخصي بشرط أن تستخدم --force-with-lease وليس --force.
Merge أم Rebase؟ مقارنة صادقة
هذا السؤال من أكثر الأسئلة جدلاً بين المطورين، والسبب أن الطريقتين تصلان إلى المحتوى نفسه للملفات، والذي يختلف هو شكل التاريخ فقط، والصورة التالية تبين الفرق على المثال نفسه بالمخرجات الحقيقية:

| Merge | Rebase | |
|---|---|---|
| شكل التاريخ | فروع تلتقي في التزامات دمج | خط واحد مستقيم |
| هل يغير التزامات موجودة؟ | لا، يضيف فقط | نعم، ينشئ نسخاً بأرقام جديدة |
| على فرع مشترك | آمن دائماً | ممنوع |
| التعارضات | تحلها مرة واحدة في التزام الدمج | قد تحلها مرة لكل التزام يعاد تطبيقه |
| القراءة والبحث | أصعب كلما كثرت الفروع | أسهل، وgit bisect يمر على خط واحد |
| التراجع عن ميزة كاملة | بالتراجع عن التزام الدمج نفسه | تحتاج أن تعرف أي الالتزامات كانت الميزة |
نقاط القوة في Merge: لا يكذب التاريخ، فهو يبين متى فتح الفرع ومتى اندمج، ولا يغير أي رقم، فلا يكسر عمل أحد. ونقاط الضعف: التزامات الدمج من نوع «Merge branch main into feature» تتكاثر وتجعل الرسم صعب القراءة.
نقاط القوة في Rebase: تاريخ نظيف كأن كل شيء كتب بالترتيب، وفرصة لترتيب التزاماتك قبل المراجعة. ونقاط الضعف: يعيد كتابة التاريخ، فهو خطير على أي فرع مشترك، والتاريخ الناتج لا يبين الترتيب الحقيقي الذي عمل به الناس.
والإجابة العملية أن أغلب الفرق لا تختار واحدة وتترك الأخرى، وإنما تستخدم كل واحدة في مكانها، وأشهر السياسات ثلاث:
- Rebase محلياً ثم Merge عبر طلب الدمج: كل شخص ينفذ
git pull --rebaseويعيد تأسيس فرعه على main قبل فتح الطلب، ثم يدمج الفرع بالتزام دمج أو بدمج سريع. وهذه أكثر السياسات توازناً. - Squash Merge في كل طلب دمج: مهما كان عدد الالتزامات في الفرع، يدخل main التزام واحد لكل طلب، وهذه هي الطريقة الأبسط للفرق الصغيرة، وسوف نشرحها في القسم التالي.
- Merge دائماً: لا أحد يعيد كتابة أي شيء، والتاريخ كما حدث تماماً، وهذه تناسب الفرق التي تقدم الأمان على شكل الرسم، أو التي فيها أعضاء جدد لم يتقنوا Rebase بعد.
وفي المنصات تفرض السياسة من إعدادات المستودع، ففي Gitea يحدد المدير طرق الدمج المسموحة لكل مستودع، والقيم التي يعرفها ملف الإعداد للخيار DEFAULT_MERGE_STYLE هي merge وrebase وrebase-merge وsquash وfast-forward-only، والافتراضي merge.
الدمج مع الضغط Squash Merge
المشكلة التي يحلها Squash هي نفسها التي حلتها إعادة التأسيس التفاعلية، أي الالتزامات الصغيرة الكثيرة في الفرع، ولكن بطريقة أبسط لا تحتاج أن يتقن كل شخص في الفريق rebase -i: عند الدمج يأخذ Git كل تغييرات الفرع ويضعها في التزام واحد جديد على main. وهذا ما يفعله خيار Squash عند دمج طلب في Gitea، وما يصفه GitHub بأن التزامات الطلب تضغط في التزام واحد، والأمر نفسه على جهازك هو:
git switch main
git merge --squash feature/discount
git status --short
git commit -qm "Add discount (#12)"
git log --oneline --graph --all
git branch -d feature/discountوالمخرج سوف يكون كما يلي:
Updating cd33688..32f861c
Fast-forward
Squash commit -- not updating HEAD
README.md | 3 +++
app.py | 3 +++
2 files changed, 6 insertions(+)
create mode 100644 README.md
A README.md
M app.py
* 3788cb0 Add discount (#12)
| * 32f861c docs
| * 81a05f8 fix rounding
| * db33bec Add discount
|/
* cd33688 Add price function
error: the branch 'feature/discount' is not fully merged
hint: If you are sure you want to delete it, run 'git branch -D feature/discount'
hint: Disable this message with "git config set advice.forceDeleteBranch false"في المخرج أعلاه لاحظ التالي:
- العبارة
not updating HEADتعني أن الأمر لم ينشئ أي التزام، وإنما وضع كل التغييرات في منطقة التجهيز، كما يبينgit status، وأنت من ينشئ الالتزام برسالتك، وفيها رقم طلب الدمج كما تفعل المنصات. - الالتزام 3788cb0 على main له أب واحد، فلا يوجد أي رابط بينه وبين التزامات الفرع الثلاثة.
- ولهذا السبب رفض Git حذف الفرع بالأمر
-d، فهو يرى أن التزامات الفرع لم تدخل main، وهو محق من جهة التاريخ، لذلك تحذف المنصات الفرع بعد Squash، وعلى جهازك تحذفه بالأمرgit branch -Dبعد أن تتأكد أن الطلب دمج.
والصورة التالية تلخص ما حدث:

ومتى تستخدمه؟ عندما يكون كل طلب دمج ميزة أو إصلاحاً واحداً، وهذا هو الحال في أغلب الفرق الصغيرة، فيصبح تاريخ main قائمة مقروءة من الطلبات، والتراجع عن طلب كامل يكون بالتراجع عن التزام واحد. ومتى لا تستخدمه؟ عندما يكون الطلب كبيراً فيه خطوات مستقلة تريد أن تبقى منفصلة في التاريخ، وعندما يكون الفرع طويل العمر وتريد أن تستمر عليه بعد الدمج، والسبب أن Git لا يعرف أن التزاماته دخلت main، فإذا دمجته مرة أخرى فسوف يحاول أن يدخل التغييرات نفسها من جديد وتظهر تعارضات. ويذكر توثيق GitHub نقطة ضعف أخرى، وهي أنك تفقد الأرقام الأصلية للالتزامات وتفاصيل كاتب كل التزام داخل الطلب.
الانتقاء Cherry-pick: إصلاح واحد إلى فرع الإصدار
لنفرض أنك أصدرت النسخة v1.0.0 لعميل، وأنشأت لها فرعاً اسمه release/1.0 يمثل ما يعمل عنده الآن، ثم تابع الفريق العمل على main فأضاف ميزة الخصم التي لم تختبر بعد، ثم اكتشف أحدهم خطأ في حساب مجموع السلة، فأصلحه على main. والعميل يحتاج هذا الإصلاح اليوم، ولكن دمج main في فرع الإصدار سوف يدخل معه ميزة الخصم التي لم تكتمل، وهذا غير مقبول إطلاقاً. والحل أن تنسخ التزام الإصلاح وحده، وهذا ما يفعله git cherry-pick:
git log --oneline --graph --all --decorate
git switch release/1.0
git cherry-pick -x main
git log --oneline --graph --all --decorate
git log -1 --format=%Bوالمخرج سوف يكون كما يلي:
* 53b69a9 (HEAD -> main) Ignore negative items in cart total
* a1ff393 Add discount
* 7ba1817 (tag: v1.0.0, release/1.0) Add price and cart total
Switched to branch 'release/1.0'
[release/1.0 bad4175] Ignore negative items in cart total
Date: Tue Oct 6 01:11:23 2026 +0300
1 file changed, 1 insertion(+), 1 deletion(-)
* bad4175 (HEAD -> release/1.0) Ignore negative items in cart total
| * 53b69a9 (main) Ignore negative items in cart total
| * a1ff393 Add discount
|/
* 7ba1817 (tag: v1.0.0) Add price and cart total
Ignore negative items in cart total
(cherry picked from commit 53b69a9add594888a4ed6d73a24aa809120202a2)في المخرج أعلاه لاحظ التالي:
- كتبنا
mainلأنه يشير إلى التزام الإصلاح، وفي العمل اليومي سوف تكتب رقم الالتزام نفسه، وتستطيع أن تنتقي عدة التزامات أو مدى منها مرة واحدة. - ميزة الخصم a1ff393 لم تدخل فرع الإصدار، والذي دخل هو التزام جديد bad4175 بالتغيير نفسه ورقم مختلف، أي أن الإصلاح موجود الآن مرتين في المستودع، وهذا طبيعي في الانتقاء لأنه نسخ وليس نقلاً.
- الخيار
-xأضاف إلى الرسالة السطر «cherry picked from commit»، وينصح توثيق cherry-pick باستخدامه عندما تنتقي بين فرعين عامين كما في نقل إصلاح إلى فرع إصدار قديم، حتى يعرف من يقرأ التاريخ أصل الالتزام، ولا ينصح به عند الانتقاء من فرعك الخاص لأن الرقم المذكور لن يفيد أحداً.
والصورة التالية تبين الفكرة:

وقد يتساءل البعض: لماذا لا نصلح الخطأ على فرع الإصدار أولاً ثم ننقله إلى main؟ والإجابة أن الطريقتين موجودتان، فنموذج Git Flow يصلح في فرع hotfix ثم يدمجه في الفرعين، أما موقع Trunk-Based Development فيوصي بأن يصلح الخطأ على الفرع الرئيسي أولاً مع اختبار يثبته، ثم ينتقى إلى فرع الإصدار، والسبب أن الإصلاح الذي يبدأ في فرع الإصدار قد ينسى ولا يصل إلى main، فيعود الخطأ في الإصدار التالي.
عندما يتعارض الانتقاء
الانتقاء يطبق تغييرات الالتزام على حالة فرع آخر، فإذا كان الإصلاح يعدل أسطراً تختلف في الفرع الهدف فسوف يتوقف، وهذا ما حدث معنا في أول محاولة عندما كان الإصلاح وميزة الخصم في الملف نفسه وفي أسطر متجاورة:
Auto-merging app.py
CONFLICT (content): Merge conflict in app.py
error: could not apply 7c1e28f... Ignore negative items in total
hint: After resolving the conflicts, mark them with
hint: "git add/rm <pathspec>", then run
hint: "git cherry-pick --continue".
hint: You can instead skip this commit with "git cherry-pick --skip".
hint: To abort and get back to the state before "git cherry-pick",
hint: run "git cherry-pick --abort".
hint: Disable this message with "git config set advice.mergeConflict false"والرسالة نفسها تشرح الخيارات: تحل التعارض وتنفذ git add ثم git cherry-pick --continue، أو تتخطى هذا الالتزام بالخيار --skip، أو تلغي العملية كلها بالخيار --abort. وإذا تكررت التعارضات عند الانتقاء إلى فرع إصدار، فهذه علامة على أن الفرع ابتعد كثيراً عن main، وعلى أن الإصلاح الذي تريده يعتمد على تغييرات أخرى لم تنتقها.
وقاعدة الأمان في الانتقاء أنه لا يغير التزامات موجودة، فهو آمن على أي فرع، ولكن لا تقم باستخدامه بديلاً عن الدمج لنقل ميزات كاملة بين فرعين يعيشان طويلاً، والسبب أن كل التزام سوف يظهر مرتين برقمين مختلفين، وعند الدمج بينهما لاحقاً قد تظهر تعارضات على تغييرات موجودة في الطرفين أصلاً.
التراجع: revert وreset وrestore
في دليل المبتدئين رأينا git restore للتعديلات التي لم تجهزها، وgit revert للالتزام الذي وصل إلى الفريق، وهنا سوف نضعها مع الأمر الثالث reset في صورة واحدة، لأن السؤال الحقيقي عند كل خطأ هو: هل أريد أن أضيف تاريخاً جديداً، أم أريد أن أعيد كتابة التاريخ؟ وفي مثالنا ثلاثة التزامات، وآخرها خطأ يضاعف كل الأسعار:
c85efc2 Double all prices
a893a8a Add discount
631b7eb Add price functionالأمر revert: التزام جديد يعكس القديم
git revert --no-edit HEAD
git log --onelineوالمخرج سوف يكون كما يلي:
[main 23a0f59] Revert "Double all prices"
Date: Tue Oct 6 01:12:30 2026 +0300
1 file changed, 1 insertion(+), 1 deletion(-)
23a0f59 Revert "Double all prices"
c85efc2 Double all prices
a893a8a Add discount
631b7eb Add price functionالالتزام الخطأ ما زال في التاريخ، وفوقه التزام يعكس تغييره، فالملفات رجعت كما كانت دون أن يتغير أي رقم، ولذلك فهو الطريقة الوحيدة المناسبة لفرع مشترك مثل main، وزملاؤك يسحبون الالتزام الجديد كأي التزام آخر.
الأمر restore: تعديلات لم تلتزم بها بعد
echo "oops" >> app.py
git status --short
git restore app.py
git status --shortوالمخرج سوف يكون كما يلي:
M app.py
بعد git restore لم يطبع git status شيئاً، أي أن الملف رجع إلى آخر نسخة ملتزم بها. وهذا الأمر لا يلمس التاريخ إطلاقاً، ولكنه يحذف تعديلاتك غير الملتزم بها نهائياً، ولا توجد طريقة في Git لاسترجاعها لأنها لم تدخل المستودع أصلاً.
الأمر reset: إرجاع الفرع إلى الخلف
الأمر git reset يحرك مؤشر الفرع الحالي إلى التزام سابق، فتخرج الالتزامات التي بعده من تاريخ الفرع، والسؤال هو ماذا يحدث لتغييراتها، وهنا يأتي الفرق بين الأنواع الثلاثة. وقد نفذنا كل نوع على الالتزامات الثلاثة نفسها، وبعد كل تجربة أرجعنا الفرع إلى حاله:
git reset --soft HEAD~1
git status --shortM app.pygit reset --mixed HEAD~1
git status --shortUnstaged changes after reset:
M app.py
M app.pygit reset --hard HEAD~2
git log --oneline
git status --shortHEAD is now at 631b7eb Add price function
631b7eb Add price functionفي المخرجات أعلاه لاحظ التالي:
- --soft: الالتزام خرج من التاريخ، ولكن تغييراته بقيت في منطقة التجهيز، والحرف
Mفي العمود الأول يعني أنها جاهزة للالتزام، وهذا مفيد عندما تريد أن تعيد الالتزام برسالة أخرى أو تجمع آخر ثلاثة التزامات في التزام واحد. - --mixed: وهو النوع الافتراضي إذا لم تكتب خياراً، والتغييرات بقيت في الملفات ولكنها خرجت من منطقة التجهيز، والحرف
Mانتقل إلى العمود الثاني. - --hard: الالتزامان خرجا من التاريخ، وتغييراتهما حذفت من الملفات أيضاً، و
git statusلا يطبع شيئاً، فكأنها لم تكتب.
والصورة التالية تجمع الفرق بين revert وreset، ومعها شبكة الأمان التي سوف نشرحها الآن:

git reset لإزالة التزامات دفعتها إلى فرع مشترك، والسبب أنه يعيد كتابة التاريخ، فلن يقبل السيرفر الدفع إلا بالقوة، وعندها تختفي الالتزامات من السيرفر وتبقى عند زملائك، فيعيدها أول من يدفع منهم. والطريقة الصحيحة على الفرع المشترك هي git revert. وقبل أي reset --hard نفذ git status، فإذا وجدت تعديلات لم تلتزم بها فاحفظها بالأمر git stash، لأن reflog لا يحفظ إلا ما التزمت به.شبكة الأمان reflog: استرجاع التزام «ضاع»
لنفرض أنك نفذت reset --hard وأخطأت في العدد، فحذفت التزامين بدلاً من واحد، وأحدهما فيه عمل يوم كامل، فهل ضاع؟ الإجابة لا، والسبب أن Git يسجل كل حركة لمؤشر HEAD في سجل محلي اسمه reflog، والالتزام نفسه يبقى في المستودع حتى لو لم يعد أي فرع يشير إليه. والمثال التالي يبين ذلك على مستودع جديد:
git log --oneline
git reset --hard HEAD~2
git log --oneline
git reflogوالمخرج سوف يكون كما يلي:
ab699ca Add VAT calculation
c2f8d93 Add discount
c46725a Add price function
HEAD is now at c46725a Add price function
c46725a Add price function
c46725a HEAD@{0}: reset: moving to HEAD~2
ab699ca HEAD@{1}: commit: Add VAT calculation
c2f8d93 HEAD@{2}: commit: Add discount
c46725a HEAD@{3}: commit (initial): Add price functionالسطر HEAD@{1} يعني المكان الذي كان فيه HEAD قبل حركة واحدة، وهو الالتزام ab699ca الذي اختفى من git log. والآن ننشئ عليه فرعاً حتى لا يضيع مرة أخرى، ثم نرجع إليه:
git branch rescue HEAD@{1}
git log --oneline rescue
git reset --hard rescue
git log --onelineوالمخرج سوف يكون كما يلي:
ab699ca Add VAT calculation
c2f8d93 Add discount
c46725a Add price function
HEAD is now at ab699ca Add VAT calculation
ab699ca Add VAT calculation
c2f8d93 Add discount
c46725a Add price functionوالطريقة نفسها تنقذك بعد إعادة تأسيس فاشلة أو Squash خاطئ، فكل عملية من هذه تكتب سطراً في reflog. ولكن تذكر حدود هذه الشبكة: reflog محلي على جهازك فقط ولا يدفع إلى السيرفر، والأمر git gc يحذف منه السطور القديمة، والقيم الافتراضية 90 يوماً للسطور عموماً و30 يوماً للسطور التي تشير إلى التزامات لم تعد في الفرع الحالي، فلا تؤجل الاسترجاع.
الحفظ المؤقت Stash: عمل لم يكتمل وطلب عاجل
أنت في منتصف ميزة الخصم، والملف معدل وفيه TODO، وعندك ملف ملاحظات جديد، ثم يطلب منك إصلاح عاجل على main. والتزام عمل نصف مكتمل باسم «WIP» حل ممكن، ولكنه يلوث الفرع، والانتقال إلى main بالتعديلات كما هي قد ينقلها معك أو يرفضه Git إذا تعارضت. والحل هو الأمر git stash، الذي يحفظ تعديلاتك في مكان جانبي ويعيد مجلد العمل نظيفاً:
git status --short
git stash push -u -m "discount work in progress"
git status --short
git stash list
git switch main
# ... urgent fix on main ...
git switch feature/discount
git stash pop
git status --shortوالمخرج سوف يكون كما يلي:
M app.py
?? notes.txt
Saved working directory and index state On feature/discount: discount work in progress
stash@{0}: On feature/discount: discount work in progress
Switched to branch 'main'
Switched to branch 'feature/discount'
On branch feature/discount
Changes not staged for commit:
(use "git add <file>..." to update what will be committed)
(use "git restore <file>..." to discard changes in working directory)
modified: app.py
Untracked files:
(use "git add <file>..." to include in what will be committed)
notes.txt
no changes added to commit (use "git add" and/or "git commit -a")
Dropped refs/stash@{0} (12810149eca983a9ee26d781722e7ed442471f53)
M app.py
?? notes.txtفي المخرج أعلاه لاحظ التالي:
- الخيار
-uضروري هنا، لأنgit stashدونه لا يحفظ الملفات الجديدة غير المتتبعة مثلnotes.txt، والخيار-mيعطي الحفظ اسماً تعرفه به فيgit stash listعندما يكثر. - الأمر
popأعاد التعديلات ثم حذف الحفظ من القائمة، أماgit stash applyفيعيدها ويبقي الحفظ، وهذا أفضل إذا كنت تخشى تعارضاً.
ومتى لا تستخدمه؟ عندما يكون العمل كبيراً أو سوف تتركه أياماً، والسبب أن الحفظ المؤقت محلي ولا يدفع إلى السيرفر، وينسى بسهولة في القائمة، فالأفضل في هذه الحالة التزام حقيقي على فرعك ثم ترتيبه لاحقاً بإعادة التأسيس التفاعلية.
الوسوم Tags وترقيم الإصدارات
شرحنا في دليل المبتدئين أن الوسم Tag اسم ثابت على التزام واحد، وهنا نوضح الفرق بين نوعيه، فالوسم الخفيف Lightweight Tag مجرد اسم يشير إلى التزام، والوسم المشروح Annotated Tag كائن كامل في المستودع له كاتب وتاريخ ورسالة:
git tag before-refactor
git tag -a v1.0.0 -m "Release 1.0.0"
git cat-file -t before-refactor
git cat-file -t v1.0.0
git show v1.0.0 --statوالمخرج سوف يكون كما يلي:
commit
tag
tag v1.0.0
Tagger: ArabRoot Team <[email protected]>
Date: Tue Oct 6 01:16:14 2026 +0300
Release 1.0.0
commit e25f5272b04701d836139139d65807c7f506335c
Author: ArabRoot Team <[email protected]>
Date: Tue Oct 6 01:15:50 2026 +0300
Add price function
app.py | 2 ++
1 file changed, 2 insertions(+)الأمر git cat-file -t يبين نوع الكائن، فالوسم الخفيف يشير مباشرة إلى commit، أما المشروح فهو كائن من نوع tag يحمل اسم من أنشأه وتاريخه. ولذلك يقول توثيق git tag إن الوسوم المشروحة للإصدارات والخفيفة للعلامات الخاصة أو المؤقتة، وإن بعض الأوامر مثل git describe تتجاهل الخفيفة افتراضياً، وهذا الأمر مفيد لأنه يصف أي التزام بالنسبة لآخر إصدار. فبعد أن أضفنا التزاماً جديداً بعد الوسم:
git describeوالمخرج سوف يكون كما يلي:
v1.0.0-1-gbcf6209أي التزام واحد بعد v1.0.0، ورقمه bcf6209، وهي صيغة مناسبة لتضعها في رقم بناء تطبيقك. وتذكر أن git push لا يدفع الوسوم، فادفع الوسم باسمه git push origin v1.0.0، وأدوات CI مثل Gitea Actions تستطيع أن تبني الإصدار وتنشره عندما يصل وسم جديد.
أما الأرقام نفسها فأغلب المشاريع تتبع ترقيم الإصدارات الدلالي Semantic Versioning 2.0.0 بالصيغة MAJOR.MINOR.PATCH، فتزيد PATCH عند إصلاح لا يكسر التوافق، مثل الإصلاح الذي انتقيناه إلى v1.0.1، وتزيد MINOR عند إضافة ميزة متوافقة مع ما قبلها مثل ميزة الخصم في v1.1.0، وتزيد MAJOR عند تغيير يكسر التوافق في الواجهة العامة Public API فتصبح v2.0.0. وتنص المواصفة أيضاً على أن الإصدارات 0.y.z مرحلة تطوير أولى يمكن أن يتغير فيها أي شيء، فلا يعتبر فيها الواجهة العامة مستقرة.
التعارضات Conflicts: لماذا تحدث وكيف تقللها
شرحنا في دليل المبتدئين التعارض الأول وعلاماته وحله، والفكرة باختصار أن Git يدمج تلقائياً كل ما لا يتعارض، ويتوقف فقط عندما يغير الطرفان السطر نفسه بطريقتين، وهذا يحدث في الدمج وفي إعادة التأسيس وفي الانتقاء وفي stash pop بالطريقة نفسها. وفي مثالنا فرع يجعل الخصم 20% لعملاء VIP، وmain جعل الخصم يقرب إلى منزلتين، والسطر نفسه تغير في الطرفين:
git config rerere.enabled true
git merge feature/vip
git status --short
cat app.pyوالمخرج سوف يكون كما يلي:
Auto-merging app.py
CONFLICT (content): Merge conflict in app.py
Automatic merge failed; fix conflicts and then commit the result.
Recorded preimage for 'app.py'
UU app.py
def discount(x):
<<<<<<< HEAD
return round(x * 0.9, 2)
=======
return x * 0.8
>>>>>>> feature/vipما بين <<<<<<< HEAD و======= هو نسختك أي main، وما بعدها حتى اسم الفرع هو النسخة القادمة، والحرفان UU يعنيان أن الملف معدل في الطرفين ولم يحل. والحل الصحيح هنا لا يأخذ أحد الطرفين، وإنما يجمع القرارين، أي خصم 20% مع التقريب، فنكتب الملف هكذا:
def discount(x):
return round(x * 0.8, 2)ثم نكمل الدمج:
git add app.py
git commit --no-editوالمخرج سوف يكون كما يلي:
Recorded resolution for 'app.py'.
[main fccff21] Merge branch 'feature/vip'الأمر rerere: لا تحل التعارض نفسه مرتين
لاحظ السطرين Recorded preimage وRecorded resolution في المخرجين أعلاه، فهما من ميزة اسمها rerere، واختصارها Reuse Recorded Resolution، وقد فعلناها بالإعداد rerere.enabled في أول المثال. وهذه الميزة تسجل شكل التعارض والحل الذي كتبته، فإذا ظهر التعارض نفسه مرة أخرى طبقت الحل تلقائياً. ولنختبر ذلك، نلغي الدمج ونعيده:
git reset --hard HEAD~1
git merge feature/vip
cat app.py
git merge --abortوالمخرج سوف يكون كما يلي:
HEAD is now at 86d543f Round discounted prices
Auto-merging app.py
CONFLICT (content): Merge conflict in app.py
Automatic merge failed; fix conflicts and then commit the result.
Resolved 'app.py' using previous resolution.
def discount(x):
return round(x * 0.8, 2)Git ما زال يعتبر الدمج متوقفاً حتى تراجع الملف وتنفذ git add، ولكن الملف نفسه يحمل الحل الذي كتبته سابقاً، وهذا مفيد جداً مع إعادة التأسيس المتكررة لفرع طويل، أو عندما تلغي دمجاً ثم تعيده.
أدوات حل التعارض
حل التعارض في محرر نصوص يكفي في الملفات الصغيرة، أما في التعارضات الكبيرة فالأمر git mergetool يفتح لكل ملف متعارض أداة رسومية تعرض نسختك والنسخة القادمة والنسخة المشتركة جنباً إلى جنب، مثل meld وkdiff3 وvimdiff، والأمر git mergetool --tool-help يعرض الأدوات المتاحة على جهازك. وأغلب المحررات الحديثة مثل VS Code تعرض التعارض بأزرار تختار بها النسخة دون أي إعداد.
كيف تقلل التعارضات
- طلبات دمج صغيرة: كلما قل عدد الأسطر التي يغيرها الفرع قلت فرصة أن يغيرها شخص آخر، والطلب الصغير يراجع ويدمج في ساعات وليس في أسابيع.
- اسحب كثيراً: نفذ
git pull --rebaseعلى main وأعد تأسيس فرعك عليه كل يوم، فتحل تعارضاً صغيراً اليوم بدلاً من تعارضات كثيرة يوم الدمج. - لا تخلط التنسيق بالمنطق: تغيير المسافات أو إعادة تنسيق ملف كامل في الطلب نفسه مع تعديل منطقي يجعل كل سطر في الملف متعارضاً مع أي شخص آخر، فاجعل التنسيق في طلب منفصل، أو استخدم أداة تنسيق Formatter موحدة يشغلها الجميع.
- تكلم مع زملائك: إذا كنتما ستعدلان الملف نفسه في الأسبوع نفسه فاتفقا على الترتيب، وهذه نصيحة لا تحتاج أي أمر.
العمل مع الفريق: طلبات الدمج وحماية الفروع
في الفرق لا يدفع أحد إلى main مباشرة، وإنما يعمل كل شخص على فرع ويفتح طلب دمج، ويسمى Pull Request في Gitea وGitHub وMerge Request في GitLab، والفكرة واحدة في الثلاثة: الزملاء يرون الفروقات ويعلقون على الأسطر، وخط CI يشغل الاختبارات على الفرع، ثم يدمج الطلب بالطريقة التي يسمح بها المستودع. وإذا كنت تريد هذا كله على سيرفرك فقد شرحنا تثبيت Gitea، وتشغيل الاختبارات والبناء بأداة Gitea Actions.
ولكن الطلب وحده لا يمنع أحداً من أن يدفع إلى main مباشرة، أو أن يدمج طلباً فشلت اختباراته، ولذلك توفر المنصات حماية الفروع Branch Protection، وفي Gitea تجد في إعدادات المستودع قواعد الحماية التالية:
- منع الدفع المباشر: لا يتغير الفرع إلا بدمج طلب، أو تسمح بالدفع لقائمة محددة فقط.
- منع الدفع بالقوة Force Push: فلا يستطيع أحد أن يعيد كتابة تاريخ main، وهذا وحده يمنع أخطر خطأ في هذا المقال.
- عدد الموافقات المطلوبة: لا يدمج الطلب قبل موافقة عدد من المراجعين، مع خيار يلغي الموافقات القديمة إذا أضيفت التزامات جديدة بعدها.
- فحوص الحالة الإلزامية Status Checks: لا يدمج الطلب حتى تنجح مهام CI التي تحددها بأسمائها.
- إلزام المديرين بالقواعد: حتى لا يتجاوزها أحد في الاستعجال.
وفي GitHub تمنع كل قاعدة حماية الدفع بالقوة وحذف الفرع افتراضياً، وتستطيع أن تشترط الموافقات ونجاح الفحوص بالطريقة نفسها.
الدفع بالقوة الآمن: --force-with-lease
بعد إعادة تأسيس فرعك أو تعديل آخر التزام فيه، سوف يرفض السيرفر الدفع لأن تاريخك لم يعد امتداداً لتاريخه، وهنا تحتاج إلى الدفع بالقوة. والمشكلة أن --force يكتب نسختك فوق ما على السيرفر مهما كان، فإذا كان زميلك قد دفع التزاماً إلى الفرع نفسه بعد آخر سحب لك فإنه يحذف التزامه دون أي تحذير. والخيار --force-with-lease يضيف شرطاً: لا تكتب فوق الفرع إلا إذا كان على السيرفر ما زال كما رأيته آخر مرة. وفي مثالنا عدلنا آخر التزام بالأمر --amend، وفي الوقت نفسه دفع شخص آخر التزاماً إلى الفرع نفسه:
git push --force-with-lease origin main
git fetch origin
git push --force-with-lease --force-if-includes origin main
git push --force-with-lease origin mainوالمخرج سوف يكون كما يلي:
To ../remote.git
! [rejected] main -> main (stale info)
error: failed to push some refs to '../remote.git'
From ../remote
1488c28..94eb265 main -> origin/main
To ../remote.git
! [rejected] main -> main (remote ref updated since checkout)
error: failed to push some refs to '../remote.git'
hint: Updates were rejected because the tip of the remote-tracking branch has
hint: been updated since the last checkout. If you want to integrate the
hint: remote changes, use 'git pull' before pushing again.
hint: See the 'Note about fast-forwards' in 'git push --help' for details.
To ../remote.git
+ 94eb265...1bae812 main -> main (forced update)في المخرج أعلاه لاحظ التالي:
- المحاولة الأولى رفضت بالسبب
stale info، أي أن السيرفر تغير منذ آخر مرة رأيته، وهذا بالضبط ما نريده، فالتزام زميلك محمي. - ثم نفذنا
git fetch، فأصبح Git «يعرف» الحالة الجديدة للسيرفر، مع أننا لم ندمج التزام زميلك في فرعنا. وهنا يأتي الخيار--force-if-includes، الذي يتحقق أن ما جلبته دخل فعلاً في فرعك، فرفض الدفع. - والمحاولة الأخيرة دون
--force-if-includesنجحت، وحذفت التزام زميلك 94eb265 من السيرفر، والسبب أن الجلب حدث الحالة التي يقارن بها--force-with-lease، وهذا التحذير مذكور صراحة في توثيق git push، خصوصاً مع أي أداة تنفذgit fetchفي الخلفية تلقائياً.
لذلك فالصيغة التي ننصح بها عند الدفع بالقوة إلى فرعك هي git push --force-with-lease --force-if-includes، ولا تقم بالدفع بالقوة إلى main أو أي فرع مشترك بأي صيغة، واجعل حماية الفرع تمنع ذلك على السيرفر حتى لا تعتمد على ذاكرة أحد.
استراتيجيات التفرع: Git Flow وGitHub Flow وTrunk-Based
الأوامر السابقة أدوات، والاستراتيجية هي الاتفاق في الفريق على متى تفتح الفروع وكم تعيش وكيف تصل إلى الإنتاج Production، والصورة التالية تقارن بين أشهر ثلاث استراتيجيات:

Git Flow: نموذج نشره Vincent Driessen في يناير 2010، فيه فرعان دائمان هما main للإنتاج وdevelop للتطوير، وفروع feature تخرج من develop وتعود إليه، وفروع release لتجهيز الإصدار، وفروع hotfix للإصلاحات العاجلة تدمج في main وdevelop معاً. وقد أضاف الكاتب نفسه في مارس 2020 ملاحظة في أعلى المقال يقول فيها إن هذا النموذج ليس الأنسب لفرق تطبيقات الويب التي تنشر باستمرار، وينصحها بنموذج أبسط مثل GitHub Flow، ويبقي Git Flow للبرامج التي تصدر بأرقام إصدارات صريحة أو تدعم أكثر من إصدار في الوقت نفسه.
GitHub Flow: فرع رئيسي واحد قابل للنشر دائماً، وكل تغيير في فرع قصير يفتح له طلب دمج، ويراجع وتنجح اختباراته، ثم يدمج ويحذف الفرع، كما في وصف GitHub. ولا توجد فروع develop أو release، والنشر يتم بعد الدمج.
Trunk-Based Development: كل المطورين يدمجون في فرع واحد يسمى trunk، ويقاومون إنشاء أي فرع آخر يعيش طويلاً، والفروع إن وجدت تعيش ساعات أو يوماً أو يومين للمراجعة وCI، والميزات غير المكتملة تدمج مخفية خلف Feature Flags، وعند الحاجة يقطع فرع إصدار من trunk قبل الإصدار مباشرة، والإصلاحات تصل إليه بالانتقاء ولا يدمج في trunk أبداً. وقد شرحنا هذه الطريقة بالتفصيل ومثالها العملي وكيف تنقل فريقك إليها من Git Flow في دليل التطوير على الفرع الرئيسي Trunk-Based.
وأي واحدة تناسب فريقاً صغيراً؟ في أغلب الحالات GitHub Flow مع حماية main وفحوص CI إلزامية وSquash Merge، وهو في الواقع قريب جداً من Trunk-Based بفروع قصيرة، وإذا احتجت أن تدعم إصداراً قديماً عند عميل فأضف فرع إصدار عند الحاجة فقط وانقل إليه الإصلاحات بالانتقاء كما فعلنا في قسم Cherry-pick. أما Git Flow فلا ننصح به لفريق صغير ينشر تطبيق ويب، والسبب أن فرع develop الدائم يؤخر وصول كل تغيير إلى الإنتاج ويضاعف التعارضات. وأين يختبر فريق ضمان الجودة QA إذا لم يكن هناك فرع develop؟ وكيف تعطي كل طلب دمج بيئة معاينة Preview خاصة به، وتنقل الإصدار نفسه من الاختبار إلى الإنتاج؟ هذا هو موضوع دليل البيئات ومعاينة الميزات وGitOps للفرق.
البحث عن الالتزام الذي كسر البرنامج: git bisect
لنفرض أن اختبار الخصم كان ينجح في الإصدار v1.0.0، وبعد تسعة التزامات من زملاء مختلفين أصبح يفشل، ولا أحد يعرف أي التزام هو السبب. والطريقة البسيطة أن ترجع إلى كل التزام وتختبره، وهذا ممكن مع تسعة التزامات، ولكنه غير عملي مع مئات الالتزامات. والأمر git bisect يستخدم البحث الثنائي Binary Search، فتعطيه التزاماً تعرف أنه سليم وآخر تعرف أنه معطوب، فيختار الالتزام الذي في المنتصف، وبحسب نتيجته يستبعد نصف الالتزامات، وهكذا حتى يصل إلى الالتزام الأول المعطوب. والأفضل من ذلك أن تعطيه أمراً يختبر به كل التزام بنفسه، وهذا هو الاختبار في مثالنا:
from app import discount
r = discount(100)
if r != 90:
print("FAIL: discount(100) =", r)
raise SystemExit(1)
print("ok")ثم نبدأ البحث:
python test_app.py
git bisect start
git bisect bad HEAD
git bisect good v1.0.0
git bisect run python test_app.py
git bisect resetوالمخرج سوف يكون كما يلي:
FAIL: discount(100) = 80.0
status: waiting for both good and bad commits
status: waiting for good commit(s), bad commit known
Bisecting: 4 revisions left to test after this (roughly 2 steps)
[1ab5bcd2ac81c5afc02c0d5e34723d97da37354d] Add currency format
running 'python' 'test_app.py'
ok
Bisecting: 2 revisions left to test after this (roughly 1 step)
[d84756a272bb38ee02f8830a7e937e4dc8c36bba] Tune discount for campaign
running 'python' 'test_app.py'
FAIL: discount(100) = 80.0
Bisecting: 0 revisions left to test after this (roughly 0 steps)
[74de293a5c70f1435292de7b381a7509ddaecfd4] Refactor pricing module
running 'python' 'test_app.py'
ok
d84756a272bb38ee02f8830a7e937e4dc8c36bba is the first bad commit
commit d84756a272bb38ee02f8830a7e937e4dc8c36bba
Author: ArabRoot Team <[email protected]>
Date: Tue Oct 6 01:23:39 2026 +0300
Tune discount for campaign
CHANGELOG.md | 1 +
app.py | 2 +-
2 files changed, 2 insertions(+), 1 deletion(-)
bisect found first bad commit
Previous HEAD position was 74de293 Refactor pricing module
Switched to branch 'main'في المخرج أعلاه لاحظ التالي:
- من تسعة التزامات اختبر Git ثلاثة فقط، وفي كل خطوة يطبع كم بقي، ومع ألف التزام لن يحتاج أكثر من عشر خطوات تقريباً.
- الأمر
git bisect runيعتمد على رمز الخروج Exit Code للاختبار، فالصفر يعني سليم، والأرقام من 1 إلى 127 ما عدا 125 تعني معطوب، والرقم 125 يعني أن هذا الالتزام لا يمكن اختباره فيتخطاه، مثل التزام لا يبنى أصلاً. - النتيجة هي «Tune discount for campaign» الذي غير الخصم إلى 20%، ولاحظ أن رسالته لا توحي بأنه يكسر شيئاً، وهذا هو الوضع الطبيعي في الحياة الحقيقية.
- الأمر
git bisect resetيرجعك إلى الفرع الذي كنت فيه، فلا تنسه.
وهنا تظهر فائدة التاريخ النظيف، فالبحث الثنائي يعمل على أي تاريخ، ولكنه أسهل عندما يكون كل التزام يبنى وتنجح اختباراته، وعندما لا يكون التاريخ مليئاً بالتزامات «WIP» المكسورة التي يجب أن تتخطاها.
الأخطاء الأكثر شيوعاً
الدفع بالقوة إلى main
شخص أعاد تأسيس main عنده أو نفذ reset، ثم رفض السيرفر الدفع فأضاف --force، فاختفت من السيرفر التزامات زملائه. والحل الوقائي هو حماية الفرع على السيرفر كما شرحنا في قسم العمل مع الفريق، وإذا حدث فلا تقم بأي دفع آخر، والتزامات الزملاء ما زالت في مستودعاتهم المحلية وفي reflog عند من دفع، فيعيدها من يملك آخر نسخة صحيحة.
إعادة تأسيس فرع مشترك
وهي القاعدة الذهبية التي شرحناها في قسم Rebase، والعلامة التي تبين أنها حدثت أن تظهر لك التزامات مكررة بالرسالة نفسها وأرقام مختلفة بعد السحب. وإذا كان الفرع لك وحدك فلا مشكلة، أما إذا كان مشتركاً فالقاعدة أن يتفق الفريق على أن الفروع المشتركة تدمج فقط.
التزام سر ثم «حذفه»
شخص التزم ملف .env فيه كلمة مرور قاعدة البيانات، ثم انتبه فحذفه في التزام جديد، فهل انتهت المشكلة؟ المثال التالي يجيب:
git rm -q .env
git commit -qm "Remove .env"
ls -a
git log --oneline -- .env
git show HEAD~1:.envوالمخرج سوف يكون كما يلي:
.
..
.git
app.py
0c48231 Remove .env
c97e126 Add config
DB_PASSWORD=S3cr3t-Exampleالملف اختفى من المجلد، ولكن git log يجد الالتزام الذي أضافه، وgit show يطبع كلمة المرور كاملة من الالتزام السابق، وأي شخص لديه نسخة من المستودع يستطيع أن يفعل ذلك. لذلك فالترتيب الصحيح هو:
- ألغ السر فوراً وأصدر بديلاً له، أي غير كلمة المرور وألغ المفتاح من الخدمة، وهذه هي الخطوة الأولى التي يوصي بها GitHub، لأن السر الملغى لم يعد يفتح شيئاً.
- إذا كان لا بد من إزالته من التاريخ فاستخدم git filter-repo، وهي الأداة التي يوصي بها مشروع Git بدلاً من git filter-branch القديمة، والأمر
git filter-repo --path .env --invert-pathsيحذف الملف من كل التزامات المستودع، ثم تدفع النتيجة بالقوة وتطلب من الجميع أن يستنسخوا المستودع من جديد. - تذكر أن إعادة الكتابة لا تصل إلى النسخ والتفرعات Forks التي عند الآخرين، وأن المنصات قد تحتفظ بنسخ في طلبات الدمج والصفحات المخزنة، كما يذكر توثيق GitHub نفسه، ولهذا فالخطوة الأولى هي الإلغاء وليس التنظيف.
وأين تضع الأسرار بدلاً من المستودع؟ شرحنا ذلك في قائمة تحقق المطور: الإعدادات والأسرار خارج ال Image، وما لا يجب أن يدخل المستودع عموماً في دليل المبتدئين.
أخطاء أخرى تتكرر
- reset --hard فوق تعديلات لم تلتزم بها: reflog لا ينقذها لأنها لم تدخل المستودع، فنفذ
git statusأولاً، واحفظ ما تحتاجه بالأمرgit stash. - فرع يعيش أسابيع: كل يوم إضافي يزيد التعارضات يوم الدمج، فقسم العمل إلى طلبات صغيرة.
- Squash ثم الاستمرار على الفرع نفسه: Git لا يعرف أن التزاماته دخلت main، فافتح فرعاً جديداً من main بعد كل دمج.
- نسيان git bisect reset: تبقى في التزام قديم دون فرع، ثم تلتزم عليه وتظن أنك في main.
- نقل وسم إصدار منشور: أصدر رقماً جديداً بدلاً من ذلك.
جدول مختصر: الموقف والأمر
| الموقف | الأمر | يعيد كتابة التاريخ؟ |
|---|---|---|
| دمج فرع ميزة مع إبقاء أثره | git merge --no-ff feature | لا |
| تحديث فرعك المحلي دون التزام دمج | git merge --ff-only origin/main | لا |
| سحب عمل الزملاء فوق التزاماتك المحلية | git pull --rebase | نعم، لالتزاماتك المحلية فقط |
| نقل فرعك فوق آخر main | git rebase main | نعم |
| ترتيب التزاماتك قبل طلب الدمج | git rebase -i main | نعم |
| طلب كامل في التزام واحد | git merge --squash feature | لا |
| نسخ إصلاح إلى فرع إصدار | git cherry-pick -x <hash> | لا |
| التراجع عن التزام دفعته | git revert <hash> | لا |
| إلغاء تعديل لم تلتزم به | git restore <file> | لا، ويحذف التعديل نهائياً |
| إلغاء آخر التزام محلي مع إبقاء التغييرات | git reset --soft HEAD~1 | نعم |
| حذف آخر التزام محلي وتغييراته | git reset --hard HEAD~1 | نعم |
| استرجاع التزام بعد reset أو rebase | git reflog ثم git branch rescue HEAD@{1} | لا |
| حفظ عمل غير مكتمل مؤقتاً | git stash push -u -m "..." | لا |
| وسم إصدار | git tag -a v1.2.0 -m "..." ثم git push origin v1.2.0 | لا |
| الدفع بعد إعادة تأسيس فرعك | git push --force-with-lease --force-if-includes | نعم، على السيرفر |
| البحث عن الالتزام الذي كسر اختباراً | git bisect run <test> | لا |
| إعادة استخدام حلول التعارضات | git config --global rerere.enabled true | لا |
تدريب عملي
- أنشئ مستودعاً فيه فرع بخمسة التزامات، منها التزام «WIP» والتزام «fix typo»، ثم رتبها بالأمر
git rebase -iإلى التزامين، ثم نفذgit reset --hard HEAD~2واسترجعهما منgit reflog. - على Gitea عندك، فعل حماية main بمنع الدفع المباشر والدفع بالقوة واشترط نجاح مهمة CI، ثم حاول أن تدفع إلى main مباشرة وأن تدفع بالقوة، واقرأ رسالة الرفض.
الخلاصة
وصلنا لنهاية الموضوع، وأهم ما فيه:
- الدمج يضيف ولا يغير، فهو آمن على كل الفروع، والدمج السريع يحرك المؤشر فقط، والدمج الثلاثي أو
--no-ffينشئ التزام دمج يحفظ أثر الفرع. - إعادة التأسيس تنشئ التزامات جديدة بأرقام جديدة، فاستخدمها لعملك المحلي ولفرعك الشخصي فقط، ولا تقم بها على فرع سحبه غيرك.
git pull --rebaseوgit rebase -iيعطيانك تاريخاً نظيفاً قبل أن يراه أحد، وSquash Merge يعطيك النتيجة نفسها على main دون أن يتقن كل الفريق Rebase.- الانتقاء ينسخ التزاماً برقم جديد، وهو الطريقة الصحيحة لنقل إصلاح إلى فرع إصدار، مع
-xبين الفروع العامة. - revert للفروع المشتركة، وreset لعملك المحلي، وreflog يحفظ لك الطريق إلى أي التزام لمدة محدودة وعلى جهازك فقط، وما لم تلتزم به لا ينقذه شيء.
- احم main على السيرفر من الدفع المباشر والدفع بالقوة، واشترط المراجعة ونجاح CI، وادفع إلى فرعك بالقوة بالصيغة
--force-with-lease --force-if-includesفقط. - لفريق صغير: فرع رئيسي واحد وطلبات دمج صغيرة وSquash وفروع إصدار عند الحاجة فقط.
- السر الذي دخل المستودع يلغى أولاً، ثم ينظف التاريخ إذا لزم بأداة git filter-repo.
دليلإدارة النسخ وGit للمبتدئين: لماذا نحتاجها وكيف تعمللماذا تضيع الملفات بأسماء final_v2 ويمسح الزملاء عمل بعضهم، وكيف يحل Git ذلك على مشروع حقيقي من أول commit.
دليلGitea: خادم Git خاص لفريقك على سيرفرككيف تثبت Gitea على سيرفرك حتى يبقى كود فريقك عندك بتجربة تشبه GitHub، مع النسخ الاحتياطي والتحديث.
دليلGitea Actions: تشغيل CI/CD وبناء Docker Images على سيرفركاجعل سيرفرك يختبر الكود ويبني Docker Images تلقائياً مع كل push، دون أن يخرج كودك أو أسرارك إلى خدمة CI خارجية.
دليلTrunk-Based Development: فروع قصيرة ودمج يوميلماذا تنفجر الفروع الطويلة يوم الإصدار، وكيف يدمج الفريق عمله في main كل يوم بأمان مع CI وFeature Flags.
دليلمن الفرع إلى الإنتاج: البيئات وGitOps للفرقكيف يختبر فريقك كل ميزة وحدها ويعرضها على العميل دون أن تصل ميزة غير جاهزة إلى الإنتاج.
دليلقبل أن تنشر تطبيقك على خادمك: قائمة تحقق للمطورالأخطاء التي لا تظهر إلا بعد نشر تطبيقك في Docker خلف Reverse Proxy، وكيف تعالج كل واحد منها قبل أن يصل إلى المستخدمين.سجل التحديثات
- أكتوبر 2026: كتابة المقال، وتنفيذ كل الأمثلة على Git 2.48.1 في مستودعات محلية، مع سيرفر Git محلي في مستودع Bare لأمثلة الدفع والسحب.