التحكم في الإصدارات وGit
أنظمة التحكم في الإصدارات (version control systems / VCSs) هي أدواتٌ تُستخدم لتتبّع التغييرات على الكود المصدري (أو مجموعاتٍ أخرى من الملفات والمجلدات). وكما يوحي الاسم، تساعد هذه الأدوات على الحفاظ على سجلٍّ (history) من التغييرات؛ كما أنها تُسهّل التعاون بين المطوّرين. منطقيًا، تتتبّع أنظمة التحكم في الإصدارات التغييرات على مجلدٍ ومحتوياته في سلسلةٍ من اللقطات (snapshots)، حيث تجسّد كل لقطةٍ الحالة الكاملة للملفات/المجلدات داخل دليلٍ أعلى مستوى (top-level directory). كما تحتفظ أنظمة التحكم في الإصدارات ببياناتٍ وصفية مثل: من أنشأ كل لقطة، وما الرسائل المرتبطة بكل لقطة، وهكذا.
لماذا يعد التحكم في الإصدارات مفيدًا؟ حتى عندما تعمل وحدك، يتيح لك الاطلاع على اللقطات القديمة للمشروع، والاحتفاظ بسجلٍّ يوضّح سبب إجراء تغييراتٍ معيّنة، والعمل على فروع (branches) متوازيةٍ من التطوير، وغير ذلك الكثير. وعند العمل مع الآخرين، إنها أداةٌ لا تُقدَّر بثمن لمعرفة ما غيّره الآخرون، وكذلك لحل تعارضات التطوير المتزامن.
كما تتيح لك أنظمة التحكم في الإصدارات الحديثة الإجابة بسهولةٍ (وغالبًا تلقائيًا) عن أسئلةٍ مثل:
- من كتب هذه الوحدة؟
- متى عُدِّل هذا السطر بالذات من هذا الملف بالذات؟ وبواسطة من؟ ولماذا عُدِّل؟
- خلال آخر 1000 مراجعة، متى ولماذا توقّف اختبار وحدةٍ معيّن عن العمل؟
في حين توجد أنظمة تحكم في الإصدارات أخرى، فإن Git هو المعيار الفعلي (de facto) للتحكم في الإصدارات. هذه رسوم XKCD الهزلية تلتقط سمعة Git:

لأن واجهة Git تجريدٌ مُسرِّب (leaky abstraction)، فإن تعلّم Git من الأعلى إلى الأسفل (بدءًا بواجهته / واجهة سطر الأوامر) قد يقود إلى الكثير من الارتباك. من الممكن حفظ حفنةٍ من الأوامر والتفكير فيها كتعويذاتٍ سحرية، واتّباع المنهج في الرسوم الهزلية أعلاه كلما حدث خطأٌ ما.
في حين أن واجهة Git قبيحةٌ بلا خلاف، فإن تصميمها الأساسي وأفكارها جميلة. فبينما يجب حفظ واجهةٍ قبيحةٍ عن ظهر قلب، يمكن فهم تصميمٍ جميل. لهذا السبب، نقدم شرحًا تصاعديًا (bottom-up) لـGit، بدءًا من نموذج البيانات (data model) الخاص به، ثم تغطية واجهة سطر الأوامر لاحقًا. وبمجرد فهم نموذج البيانات، يمكن فهم الأوامر بشكلٍ أفضل من حيث كيفية تلاعبها بنموذج البيانات الأساسي.
نموذج بيانات Git
تكمن براعة Git في نموذج البيانات المُصمَّم بعناية الذي يتيح كل ميزات التحكم في الإصدارات الرائعة، مثل الحفاظ على السجل، ودعم الفروع، وتمكين التعاون.
اللقطات (Snapshots)
يمثّل Git تاريخ مجموعةٍ من الملفات والمجلدات داخل دليلٍ أعلى مستوى كسلسلةٍ من اللقطات. في مصطلحات Git، يُسمّى الملف "blob"، وهو مجرد مجموعةٍ من البايتات. أما الدليل فيُسمّى "tree"، وهو يربط الأسماء بالـblob أو الـtree (وبالتالي يمكن أن تحتوي الأدلة على أدلةٍ أخرى). اللقطة هي الـtree الأعلى مستوى الذي يتم تتبّعه. على سبيل المثال، قد يكون لدينا tree على النحو التالي:
<root> (tree)
|
+- foo (tree)
| |
| + bar.txt (blob, contents = "hello world")
|
+- baz.txt (blob, contents = "git is wonderful")
يحتوي الـtree الأعلى مستوى على عنصرين: tree اسمه "foo" (يحتوي هو نفسه على عنصرٍ واحدٍ: blob اسمه "bar.txt")، وblob اسمه "baz.txt".
نمذجة التاريخ: ربط اللقطات
كيف ينبغي لنظام التحكم في الإصدارات ربط اللقطات ببعضها؟ أحد النماذج البسيطة هو امتلاك تاريخٍ خطّي. سيكون التاريخ قائمةً باللقطات بترتيبٍ زمني. ولأسبابٍ عديدة، لا يستخدم Git نموذجًا بسيطًا مثل هذا.
في Git، التاريخ هو رسم بياني موجَّه لا حلقاتيّ (directed acyclic graph / DAG) من اللقطات. قد يبدو ذلك كلفظةٍ رياضيةٍ فاخرة، لكن لا تُرعَب. كل ما يعنيه هذا أن كل لقطةٍ في Git تشير إلى مجموعةٍ من "الآباء" (parents)، أي اللقطات التي سبقتها. إنها مجموعةُ آباءٍ وليس أبًا واحدًا (كما في التاريخ الخطّي) لأن اللقطة قد تنحدر من آباء متعددين، على سبيل المثال بسبب دمج (merging) فرعين متوازيين من التطوير.
يسمّي Git هذه اللقطات "commits". قد يبدو تصوّر سجل الـcommits شيئًا مثل هذا:
o <-- o <-- o <-- o
^
\
--- o <-- o
في الرسم الحرفي أعلاه، تقابل oس الـcommits الفردية (اللقطات). تشير الأسهم إلى والد كل commit (إنها علاقة "يأتي قبل"، وليست "يأتي بعد"). بعد الـcommit الثالث، يتفرّع التاريخ إلى فرعين منفصلين. قد يقابل هذا، على سبيل المثال، تطوير ميزتين منفصلتين بالتوازي، وبشكلٍ مستقلٍ عن بعضهما. في المستقبل، قد يُدمج هذان الفرعان لإنشاء لقطةٍ جديدةٍ تدمج الميزتين معًا، منتجةً تاريخًا جديدًا يبدو هكذا، مع عرض commit الدمج المُنشأ حديثًا بخطٍ عريض:
o <-- o <-- o <-- o <---- o
^ /
\ v
--- o <-- o
الـcommits في Git غير قابلةٍ للتغيير (immutable). هذا لا يعني أنه لا يمكن تصحيح الأخطاء؛ بل إن "تعديلات" سجل الـcommits تُنشئ في الواقع commits جديدةً بالكامل، ويُحدَّث المراجع (references، انظر أدناه) لتشير إلى الجديدة.
نموذج البيانات، ككودٍ زائف (pseudocode)
قد يكون من المفيد رؤية نموذج بيانات Git مكتوبًا في شكل كودٍ زائف:
// a file is a bunch of bytes
type blob = array<byte>
// a directory contains named files and directories
type tree = map<string, tree | blob>
// a commit has parents, metadata, and the top-level tree
type commit = struct {
parents: array<commit>
author: string
message: string
snapshot: tree
}
إنه نموذجٌ نظيفٌ وبسيطٌ للتاريخ.
الكائنات والمعالجة بالمحتوى (Content-Addressing)
"الكائن" (object) هو blob أو tree أو commit:
type object = blob | tree | commit
في مخزن بيانات Git، تُعالَج جميع الكائنات بالمحتوى عبر تجزئة SHA-1 الخاصة بها.
objects = map<string, object>
def store(object):
id = sha1(object)
objects[id] = object
def load(id):
return objects[id]
تُوحَّد الـblob والـtree والـcommit بهذه الطريقة: فهي جميعًا كائنات. وعندما تشير إلى كائناتٍ أخرى، فإنها لا تحتويها فعليًا في تمثيلها على القرص، بل تحمل مرجعًا لها عبر تجزئتها.
على سبيل المثال، يبدو الـtree الخاص بهيكل الدليل المثال أعلاه (مُصوَّرًا باستخدام git cat-file -p 698281bc680d1995c5f4caaf3359721a5a58d48d) هكذا:
100644 blob 4448adbf7ecd394f42ae135bbeed9676e894af85 baz.txt
040000 tree c68d233a33c5c06e0340e4c224f0afca87c8ce87 foo
يحتوي الـtree نفسه على مؤشراتٍ لمحتوياته، baz.txt (blob) وfoo (tree). وإذا نظرنا إلى المحتويات المُعالَجة بالعنوان بالتجزئة المقابلة لـbaz.txt باستخدام git cat-file -p 4448adbf7ecd394f42ae135bbeed9676e894af85، نحصل على ما يلي:
git is wonderful
المراجع (References)
الآن، يمكن تحديد جميع اللقطات عبر تجزئات SHA-1 الخاصة بها. هذا أمرٌ غير مريح، لأن البشر ليسوا جيدين في تذكّر سلاسلٍ من 40 حرفًا سداسيًا عشريًا.
حلّ Git لهذه المشكلة هو أسماءٌ قابلةٌ للقراءة البشرية لتجزئات SHA-1، تُسمّى "مراجع" (references). المراجع هي مؤشراتٌ إلى commits. وعلى عكس الكائنات، التي غير قابلةٍ للتغيير، فإن المراجع قابلةٌ للتغيير (يمكن تحديثها لتشير إلى commit جديد). على سبيل المثال، يشير المرجع master عادةً إلى أحدث commit في فرع التطوير الرئيسي.
references = map<string, string>
def update_reference(name, id):
references[name] = id
def read_reference(name):
return references[name]
def load_reference(name_or_id):
if name_or_id in references:
return load(references[name_or_id])
else:
return load(name_or_id)
بهذا، يمكن لـGit استخدام أسماءٍ قابلةٍ للقراءة البشرية مثل "master" للإشارة إلى لقطةٍ معيّنةٍ في التاريخ، بدلًا من سلسلةٍ سداسيةٍ عشريةٍ طويلة.
أحد التفاصيل هو أننا غالبًا نريد مفهوم "أين نحن الآن" في التاريخ، حتى نعرف، عندما نلتقط لقطةً جديدة، بالنسبة إلى ماذا هي (كيف نضبط حقل parents في الـcommit). في Git، ذلك "المكان الذي نحن فيه الآن" هو مرجعٌ خاصٌّ يُسمّى "HEAD".
المستودعات (Repositories)
أخيرًا، يمكننا تعريف ما هو مستودع Git (بشكلٍ تقريبي): إنه بيانات objects وreferences.
على القرص، كل مخازن Git هي كائناتٌ ومراجع: هذا كل ما في نموذج بيانات Git. كل أوامر git تقابل تلاعبًا ما في رسم الـcommit البياني بإضافة كائناتٍ وإضافة/تحديث المراجع.
عندما تكتب أي أمرٍ، فكّر في التلاعب الذي يجريه الأمر على بنية الرسم البياني الأساسية. وعلى العكس، إذا كنت تحاول إجراء نوعٍ معيّنٍ من التغيير على رسم الـcommit البياني، مثل "تجاهل التغييرات غير الملتزمة واجعل مرجع 'master' يشير إلى commit 5d83f9e"، فمن المحتمل وجود أمرٍ لفعل ذلك (مثلًا، في هذه الحالة: git checkout master; git reset --hard 5d83f9e).
منطقة التدريج (Staging area)
هذا مفهومٌ آخر مستقلٌّ عن نموذج البيانات، لكنه جزءٌ من واجهة إنشاء الـcommits.
إحدى الطرق التي قد تتخيّل بها تنفيذ التقاط اللقطات الموصوف أعلاه هي امتلاك أمر "إنشاء لقطة" يُنشئ لقطةً جديدةً استنادًا إلى الحالة الحالية لدليل العمل (working directory). تعمل بعض أدوات التحكم في الإصدارات بهذه الطريقة، لكن ليس Git. نحن نريد لقطاتٍ نظيفة، وقد لا يكون من المثالي دائمًا صنع لقطةٍ من الحالة الحالية. على سبيل المثال، تخيّل سيناريو نفّذت فيه ميزتين منفصلتين، وتريد إنشاء commitين منفصلين، حيث يُدخل الأول الميزة الأولى، والثاني الميزة الثانية. أو تخيّل سيناريو أضفت فيه جمل طباعةٍ لتصفية الأخطاء في كل أنحاء كودك، مع إصلاحٍ لخللٍ؛ تريد الالتزام بإصلاح الخلل مع تجاهل جميع جمل الطباعة.
يستوعب Git مثل هذه السيناريوهات بالسماح لك بتحديد أيّ التعديلات ينبغي تضمينها في اللقطة التالية عبر آليةٍ تسمى "منطقة التدريج" (staging area).
واجهة سطر أوامر Git
لتجنّب تكرار المعلومات، لن نشرح الأوامر أدناه بالتفصيل في هذه الملاحظات. راجع كتاب Pro Git الموصى به بشدة لمزيدٍ من المعلومات، أو شاهد فيديو المحاضرة.
الأساسيات
git help <command>: الحصول على مساعدةٍ لأمر gitgit init: إنشاء مستودع git جديد، تُخزَّن بياناته في دليل.gitgit status: يخبرك بما يجريgit add <filename>: إضافة ملفات إلى منطقة التدريجgit commit: إنشاء commit جديد- اكتب رسائل commit جيدة!
- والمزيد من الأسباب لكتابة رسائل commit جيدة!
git log: يعرض سجلًّا مبسّطًا للتاريخgit log --all --graph --decorate: يصور التاريخ كرسمٍ بيانيٍّ موجَّهٍ لا حلقاتيgit diff <filename>: يعرض التغييرات التي أجريتها بالنسبة إلى منطقة التدريجgit diff <revision> <filename>: يعرض الفروقات في ملفٍ بين اللقطاتgit checkout <revision>: تحديث HEAD (والفرع الحالي إذا كنت تتفقد فرعًا)
التفرع والدمج
git branch: يعرض الفروعgit branch <name>: إنشاء فرعgit switch <name>: التبديل إلى فرعgit checkout -b <name>: إنشاء فرعٍ والتبديل إليه- نفس
git branch <name>; git switch <name>
- نفس
git merge <revision>: دمجٌ في الفرع الحاليgit mergetool: استخدام أداةٍ فاخرة للمساعدة في حل تعارضات الدمجgit rebase: إعادة بناء مجموعة الرقع (patches) على قاعدةٍ جديدة
المستودعات البعيدة (Remotes)
git remote: سرد المستودعات البعيدةgit remote add <name> <url>: إضافة مستودعٍ بعيدgit push <remote> <local branch>:<remote branch>: إرسال الكائنات إلى المستودع البعيد وتحديث مرجعهgit branch --set-upstream-to=<remote>/<remote branch>: إعداد المراسلة بين الفرع المحلي والفرع البعيدgit fetch: جلب الكائنات/المراجع من مستودعٍ بعيدgit pull: نفسgit fetch; git mergegit clone: تنزيل المستودع من مستودعٍ بعيد
التراجع
git commit --amend: تعديل محتويات/رسالة commitgit reset <file>: إزالة ملفٍ من التدريجgit restore: تجاهل التغييرات
Git المتقدم
git config: Git قابلٌ للتخصيص بدرجةٍ كبيرةgit clone --depth=1: استنساخٌ ضحل، دون سجل الإصدارات الكاملgit add -p: تدريجٌ تفاعليgit rebase -i: إعادة بناءٍ تفاعليةgit blame: يظهر من عدّل آخر سطرٍgit stash: إزالة التعديلات مؤقتًا من دليل العملgit bisect: بحثٌ ثنائي في التاريخ (مثلًا للانحدارات)git revert: إنشاء commit جديدٍ يعكس تأثير commitٍ سابقgit worktree: تفقّد فروعٍ متعددةٍ في الوقت نفسه.gitignore: حدّد الملفات غير المتتبَّعة عمدًا لتجاهلها
أمور متفرقة
- واجهات رسومية (GUIs): يوجد عملاء واجهاتٍ رسومية عديدةٌ لـGit. نحن شخصيًا لا نستخدمها ونستخدم واجهة سطر الأوامر بدلًا منها.
- تكامل مع مُعالج الأوامر: من المفيد جدًا وجود حالة Git كجزءٍ من موجه مُعالج الأوامر الخاص بك (zsh، bash). وغالبًا ما تكون مضمّنةً في أطرٍ مثل Oh My Zsh.
- تكامل مع المحرر: على نحوٍ مماثل لما سبق، تكاملاتٌ مفيدةٌ بميزاتٍ عديدة. fugitive.vim هو التكامل القياسي لـVim.
- سير العمل (Workflows): علّمناك نموذج البيانات، إضافةً إلى بعض الأوامر الأساسية؛ لم نخبرك بالممارسات التي ينبغي اتباعها عند العمل على مشاريع كبيرة (وهناك مقارباتٌ متعددةٌ ومختلفةٌ).
- GitHub: Git ليس GitHub. لدى GitHub طريقةٌ محددةٌ للمساهمة بالكود في مشاريع الآخرين، تُسمّى طلبات السحب (pull requests).
- مزودو Git آخرون: GitHub ليس مميزًا: هناك مضيفاتٌ عديدةٌ لمستودعات Git، مثل GitLab وBitBucket.
موارد
- Pro Git قراءةٌ موصى بها بشدة. قراءة الفصول 1–5 ستعلمك معظم ما تحتاجه لاستخدام Git بكفاءة، الآن بعد أن فهمت نموذج البيانات. الفصول اللاحقة تحتوي على مادةٍ متقدمةٍ مثيرة للاهتمام.
- Oh Shit, Git!?! دليلٌ قصيرٌ عن كيفية التعافي من بعض أخطاء Git الشائعة.
- Git for Computer Scientists شرحٌ قصيرٌ لنموذج بيانات Git، برمزٍ زائفٍ أقل ورسومٍ بيانيةٍ أكثر فخامةً من هذه الملاحظات.
- Git from the Bottom Up شرحٌ مفصّلٌ لتفاصيل تنفيذ Git بما يتجاوز نموذج البيانات فقط، للمهتمين.
- How to explain git in simple words
- Learn Git Branching لعبةٌ قائمةٌ على المتصفح تُعلّمك Git.
تمارين
- إذا لم تكن لديك أي خبرةٍ سابقة مع Git، فجرّب إما قراءة الفصلين الأولين من Pro Git أو متابعة برنامجٍ تعليميٍّ مثل Learn Git Branching. وأثناء عملك فيه، اربط أوامر Git بنموذج البيانات.
- استنسخ مستودع موقع المقرر الدراسي.
- استكشف سجل الإصدارات بتصويره كرسمٍ بياني.
- من هو آخر شخصٍ عدّل
README.md؟ (تلميح: استخدمgit logمع وسيطٍ). - ما رسالة commit المرتبطة بآخر تعديلٍ على سطر
collections:في_config.yml؟ (تلميح: استخدمgit blameوgit show).
- من الأخطاء الشائعة عند تعلّم Git الالتزام بملفاتٍ كبيرةٍ لا ينبغي أن يديرها Git، أو إضافة معلوماتٍ حساسة. جرّب إضافة ملفٍ إلى مستودعٍ، وإجراء بعض الـcommits ثم حذف ذلك الملف من السجل (وليس من أحدث commit فقط). قد تريد الاطلاع على هذا.
- استنسخ مستودعًا ما من GitHub، وعدّل أحد ملفاته الموجودة. ماذا يحدث عند تنفيذ
git stash؟ ماذا ترى عند تشغيلgit log --all --oneline؟ نفّذgit stash popللتراجع عمّا فعلته بـgit stash. في أي سيناريو قد يكون هذا مفيدًا؟ - مثل الكثير من أدوات سطر الأوامر، يوفر Git ملف إعداداتٍ (أو ملف نقطة (dotfile)) يُسمّى
~/.gitconfig. أنشئ اسمًا مستعارًا (alias) في~/.gitconfigبحيث يعطيك تنفيذgit graphمخرجاتgit log --all --graph --decorate --oneline. يمكنك فعل ذلك إما بتحرير ملف~/.gitconfigمباشرةً، أو باستخدام أمرgit configلإضافة الاسم المستعار. يمكن العثور على معلوماتٍ حول أسماء Git المستعارة هنا. - يمكنك تعريف أنماط التجاهل العامة في
~/.gitignore_globalبعد تنفيذgit config --global core.excludesfile ~/.gitignore_global. يحدد هذا موقع ملف التجاهل العام الذي سيستخدمه Git، لكنك ما زلت بحاجةٍ إلى إنشاء الملف يدويًا في ذلك المسار. أعدد ملف gitignore العام لديك لتجاهل الملفات المؤقتة الخاصة بنظام التشغيل أو المحرر، مثل.DS_Store. - اعمل fork على مستودع موقع المقرر الدراسي، وابحث عن خطأٍ إملائيٍّ أو أي تحسينٍ آخر يمكنك إجراؤه، وقدّم طلب سحبٍ (pull request) على GitHub (قد تريد الاطلاع على هذا). يُرجى تقديم طلبات سحبٍ مفيدةٍ فقط (لا تُرسل لنا رسائل مزعجة من فضلك!). إذا لم تجد تحسينًا لتجريه، يمكنك تخطّي هذا التمرين.
- تدرب على حل تعارضات الدمج بمحاكاة سيناريو تعاونٍ:
- أنشئ مستودعًا جديدًا بالأمر
git initوأنشئ ملفًا اسمهrecipe.txtبأسطرٍ قليلة (مثلًا، وصفةً بسيطة). - التزم به، ثم أنشئ فرعين:
git branch saltyوgit branch sweet. - في فرع
salty، عدّل سطرًا (مثلًا، غيّر "1 cup sugar" إلى "1 cup salt") والتزم. - في فرع
sweet، عدّل السطر نفسه بشكلٍ مختلف (مثلًا، غيّر "1 cup sugar" إلى "2 cups sugar") والتزم. - الآن بدّل إلى
masterوجرّبgit merge saltyثمgit merge sweet. ماذا يحدث؟ انظر إلى محتوياتrecipe.txt— ماذا تعني العلامات<<<<<<<و=======و>>>>>>>؟ - حلّ التعارض بتحرير الملف لإبقاء المحتوى الذي تريده، وإزالة علامات التعارض، وإكمال الدمج بالأمرين
git addوgit commit(أوgit merge --continue). بدلًا من ذلك، جرّب استخدامgit mergetoolلحل التعارض بأداة دمجٍ رسوميةٍ أو قائمةٍ على الطرفية. - استخدم
git log --graph --onelineلتصوّر سجل الدمج الذي أنشأته للتو.
- أنشئ مستودعًا جديدًا بالأمر