رفتن به محتوا
LoopX

پروژه: شبیه‌سازی کار تیمی

توی این درس یاد می‌گیری همه‌ی دوره را در یک پروژه‌ی واقعی کنار هم بگذاری: با دو کلون جدا نقش دو برنامه‌نویس (سارا و رضا) را بازی می‌کنی و چرخه‌ی کامل کار تیمی را اجرا می‌کنی: راه‌اندازی مخزن با CI و hook ها، شاخه برای هر ویژگی با commit های قراردادی، Pull Request و بازبینی کد، یک conflict واقعی، ادغام با «دکمه‌ی merge» روی main محافظت‌شده، انتشار با tag، یک hotfix با cherry-pick، و نجات یک شاخه‌ی پاک‌شده با reflog. تمرین: با دو کلون جدا نقش دو برنامه‌نویس را بازی کن.

مسئله: از دانستن دستورها تا کار کردن با تیم

Section titled “مسئله: از دانستن دستورها تا کار کردن با تیم”

تک‌تک دستورهای Git را یاد گرفتی. ولی کار تیمی یعنی ترتیب و هماهنگی: چه کسی کجا commit می‌زند، چه چیزی با PR می‌رود، conflict را چه کسی حل می‌کند، چه زمانی tag می‌خورد. این درس همان ترتیب را روی یک پروژه‌ی کوچک (یک ماشین‌حساب فروشگاه) اجرا می‌کند.

چهار نقش روی یک «سرور»: سارا و رضا هر کدام کلون خودشان را دارند و فقط روی شاخه‌ی ویژگی push می‌کنند؛ main محافظت‌شده است و فقط «دکمه‌ی merge» (کلون merger) بعد از بازبینی و CI سبز به آن می‌نویسد.
نقش چه می‌کند
سارا (~/gitlab/sara) مخزن را راه می‌اندازد؛ ویژگی تخفیف
رضا (~/gitlab/reza) بازبینی؛ ویژگی مالیات؛ رفع باگ
merger (~/gitlab/merger) نقش «دکمه‌ی Merge» در GitHub: فقط او به main می‌نویسد؛ tag و release
سرور (~/gitlab/server/shop.git) مخزن bare با hook ای که main را محافظت می‌کند

نکته‌ی صداقت: «GitHub» این‌جا یک مخزن bare محلی است؛ PR، کامنت‌های بازبینی و CI را (که ویژگی GitHub‌اند) با معادل‌های Git شبیه‌سازی کرده‌ام و هر جا شبیه‌سازی است گفته‌ام. «محافظت از main» یک hook pre-receive روی سرور است، که همان مکانیزمی است که branch protection پشت صحنه اعمال می‌کند.

قدم ۰: سرور محافظت‌شده و ابزار «CI»

Section titled “قدم ۰: سرور محافظت‌شده و ابزار «CI»”

اول «سرور». hook pre-receive فقط وقتی اجازه‌ی نوشتن روی main می‌دهد که از «دکمه‌ی merge» بیاید (در این شبیه‌سازی: متغیر LX_MERGE_BUTTON=1؛ چون سرور محلی است، متغیر محیطی به hook می‌رسد). ساخت main برای بار اول و کار روی شاخه‌های دیگر آزاد است:

mkdir -p ~/gitlab/server && cd ~/gitlab/server
git init -q --bare shop.git
cat > shop.git/hooks/pre-receive <<'EOF'
#!/bin/sh
# شبیه‌سازی «محافظت از main»: فقط دکمه‌ی merge می‌تواند main را عوض کند
zero=0000000000000000000000000000000000000000
while read old new ref; do
if [ "$ref" = "refs/heads/main" ] && [ "$old" != "$zero" ] && [ "$LX_MERGE_BUTTON" != "1" ]; then
echo "error: main محافظت‌شده است. تغییر را با Pull Request بفرست." >&2
exit 1
fi
done
EOF
chmod +x shop.git/hooks/pre-receive
echo "سرور آماده است:"; ls shop.git/hooks/pre-receive
خروجی
سرور آماده است:
shop.git/hooks/pre-receive

و ابزار آموزشی «CI» که از درس GitHub Actions می‌شناسی: مرحله‌های run یک workflow را اجرا می‌کند. (این جای runner واقعی GitHub است؛ فقط برای این شبیه‌سازی.)

Terminal window
cat > ~/mini-runner.py <<'EOF'
import subprocess, sys, yaml
wf = yaml.safe_load(open(sys.argv[1]))
ok = True
for job_id, job in wf["jobs"].items():
for step in job["steps"]:
if "uses" in step:
continue
r = subprocess.run(step["run"], shell=True, capture_output=True, text=True)
if r.returncode != 0:
print(f"✗ CI شکست خورد (job {job_id}): {step.get('name', step['run'])}")
print(r.stdout[-600:] + r.stderr[-600:])
ok = False
break
print("✓ CI سبز" if ok else "✗ CI قرمز")
sys.exit(0 if ok else 1)
EOF
echo "mini-runner آماده است"
خروجی
mini-runner آماده است

قدم ۱: سارا پروژه را راه می‌اندازد

Section titled “قدم ۱: سارا پروژه را راه می‌اندازد”

سارا یک مخزن با کد، تست، .gitignore، قاعده‌ی پیام commit (hook در یک پوشه‌ی داخل مخزن)، workflow ی CI و قالب PR می‌سازد، با commit های قراردادی (chore:، feat:، test:، ci:، docs:):

Terminal window
mkdir -p ~/gitlab/sara && cd ~/gitlab/sara && git init -q
git config user.name "Sara" && git config user.email "sara@example.com"
git remote add origin ~/gitlab/server/shop.git
mkdir -p tests .githooks .github/workflows
cat > .gitignore <<'EOF'
__pycache__/
*.pyc
.env
EOF
git add .gitignore && git commit -qm "chore: add .gitignore"
cat > shop.py <<'EOF'
def total(prices):
"""Sum of item prices."""
return round(sum(prices), 2)
EOF
cat > tests/test_shop.py <<'EOF'
import unittest
from shop import total
class TestTotal(unittest.TestCase):
def test_total(self):
self.assertEqual(total([10, 5.5]), 15.5)
if __name__ == "__main__":
unittest.main()
EOF
git add shop.py tests && git commit -qm "feat(shop): add total and its test"
cat > .githooks/commit-msg <<'EOF'
#!/bin/sh
# پیام باید Conventional Commit باشد؛ پیام‌هایی که خود Git می‌سازد (Merge، Revert، fixup) مستثنا هستند
title=$(head -1 "$1")
case "$title" in Merge*|Revert*|fixup!*|squash!*) exit 0 ;; esac
pattern='^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\([a-z0-9-]+\))?!?: .{3,}'
echo "$title" | grep -qE "$pattern" || { echo "✗ پیام باید مثل «feat(shop): add x» باشد." >&2; exit 1; }
EOF
chmod +x .githooks/commit-msg
cat > .github/workflows/ci.yml <<'EOF'
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Run tests
run: python3 -m unittest discover -s tests
EOF
printf '## What changed?\n\n## How did you test it?\n' > .github/PULL_REQUEST_TEMPLATE.md
cat > README.md <<'EOF'
# Shop
A tiny shop calculator.
## Development
- Enable hooks: `git config core.hooksPath .githooks`
- Run tests: `python3 -m unittest discover -s tests`
- Branches: `feature/...` and `fix/...`; commits: Conventional Commits.
EOF
echo "--- بررسی workflow با actionlint و اجرای محلی تست‌ها:"
actionlint -color=false; echo "actionlint کد خروج: $?"
python3 -m unittest discover -s tests 2>&1 | tail -3
git config core.hooksPath .githooks
git add -A && git commit -qm "ci: add test workflow, PR template, hooks and README"
git push -q -u origin main 2>&1 | tail -1
git log --oneline
خروجی
--- بررسی workflow با actionlint و اجرای محلی تست‌ها:
actionlint کد خروج: 0
Ran 1 test in 0.000s
OK
ee9dd31 ci: add test workflow, PR template, hooks and README
6259cd4 feat(shop): add total and its test
c1f8b99 chore: add .gitignore

سارا قبل از push خودش دو بررسی کرد: actionlint روی workflow و اجرای محلی تست‌ها. و دقت کن آخرین commit از hook خودش رد نشد چون پیامش قراردادی است. یک نکته‌ی مهم درباره‌ی خود hook: پیام‌هایی را که خود Git می‌سازد (Merge ...، Revert ...، fixup! ...) مستثنا کرده‌ام. من اول این استثنا را نگذاشته بودم و همین پروژه در قدم ۶ شکست خورد: commit ادغام رضا با پیام پیش‌فرض Merge remote-tracking branch ... توسط hook ِ خودمان رد شد و ادغام نیمه‌کاره ماند. hook ای که فقط پیام‌های خوب را بپذیرد ولی ادغام و revert را رد کند، تیم را عصبانی می‌کند. اولین push که main را می‌سازد مجاز بود (hook فقط تغییر موجود بودن main را می‌بندد).

قدم ۲: رضا کلون می‌کند؛ قاعده‌ها اعمال می‌شوند

Section titled “قدم ۲: رضا کلون می‌کند؛ قاعده‌ها اعمال می‌شوند”

رضا مخزن را می‌گیرد و (طبق README) hook ها را فعال می‌کند. سپس سه چیز را امتحان می‌کند: commit با پیام بد، push مستقیم به main، و نسخه‌ی خوب:

Terminal window
cd ~/gitlab && git clone -q server/shop.git reza && cd reza
git config user.name "Reza" && git config user.email "reza@example.com"
git config core.hooksPath .githooks
echo "=== ۱) commit با پیام بد:"
echo "# note" >> README.md
git commit -qam "updated stuff" 2>&1 | head -1
echo "=== ۲) commit خوب، ولی push مستقیم به main:"
git commit -qam "docs: add a note to README"
git push origin main 2>&1 | grep -E "error|rejected"
git reset -q --hard origin/main
echo "(commit مستقیم را برای ادامه‌ی پروژه دور ریختم)"
خروجی
=== ۱) commit با پیام بد:
✗ پیام باید مثل «feat(shop): add x» باشد.
=== ۲) commit خوب، ولی push مستقیم به main:
remote: error: main محافظت‌شده است. تغییر را با Pull Request بفرست.
! [remote rejected] main -> main (pre-receive hook declined)
error: failed to push some refs to '/home/ali/gitlab/server/shop.git'
(commit مستقیم را برای ادامه‌ی پروژه دور ریختم)

رضا دو تا از قاعده‌های تیم را عملاً دید: hook ِ commit-msg پیام بد را رد کرد و محافظت از main push مستقیم را. حالا راه درست: شاخه‌ی ویژگی.

قدم ۳: دو ویژگی هم‌زمان، دو شاخه

Section titled “قدم ۳: دو ویژگی هم‌زمان، دو شاخه”

سارا ویژگی تخفیف را روی feature/discount می‌سازد و رضا ویژگی مالیات را روی feature/tax. هر دو همان تابع total را عوض می‌کنند (چون ویژگی‌هایشان به هم مربوط‌اند؛ conflict را عمداً پیش می‌آوریم). سارا اول فقط کد را می‌فرستد:

Terminal window
cd ~/gitlab/sara
git switch -qc feature/discount
cat > shop.py <<'EOF'
def total(prices, discount=0):
"""Sum of item prices with an optional percent discount."""
amount = sum(prices) * (100 - discount) / 100
return round(amount, 2)
EOF
git commit -qam "feat(shop): add percent discount to total"
git push -q -u origin feature/discount 2>&1 | tail -1
echo "--- سارا: feature/discount push شد"
cd ~/gitlab/reza
git switch -qc feature/tax
cat > shop.py <<'EOF'
def total(prices, tax=0):
"""Sum of item prices with an optional percent tax."""
amount = sum(prices) * (100 + tax) / 100
return round(amount, 2)
EOF
cat > tests/test_tax.py <<'EOF'
import unittest
from shop import total
class TestTax(unittest.TestCase):
def test_tax(self):
self.assertEqual(total([100], tax=9), 109.0)
if __name__ == "__main__":
unittest.main()
EOF
git add -A && git commit -qm "feat(shop): add percent tax to total"
git push -q -u origin feature/tax 2>&1 | tail -1
echo "--- رضا: feature/tax push شد"
git branch -r
خروجی
--- سارا: feature/discount push شد
--- رضا: feature/tax push شد
origin/HEAD -> origin/main
origin/feature/tax
origin/main

قدم ۴: Pull Request شماره‌ی ۱ و بازبینی

Section titled “قدم ۴: Pull Request شماره‌ی ۱ و بازبینی”

سارا PR باز می‌کند (شبیه‌سازی: شاخه‌ی ارسال‌شده). رضا بازبینی می‌کند: شاخه را می‌گیرد، «Files changed» را با diff سه‌نقطه‌ای می‌بیند و آن را اجرا می‌کند:

Terminal window
cd ~/gitlab/reza
git fetch -q origin
echo "=== commit های PR #1 (feature/discount):"
git log --oneline main..origin/feature/discount
echo "=== Files changed:"
git diff --stat main...origin/feature/discount
echo "=== اجرا و آزمایش روی شاخه‌ی PR:"
git switch -q --detach origin/feature/discount
python3 -c "from shop import total; print('total([100], 10) =', total([100], 10))"
python3 -m unittest discover -s tests 2>&1 | tail -3
git switch -q feature/tax
خروجی
=== commit های PR #1 (feature/discount):
50f2cc3 feat(shop): add percent discount to total
=== Files changed:
shop.py | 7 ++++---
1 file changed, 4 insertions(+), 3 deletions(-)
=== اجرا و آزمایش روی شاخه‌ی PR:
total([100], 10) = 90.0
Ran 1 test in 0.000s
OK

بازبین دو نکته می‌بیند (کامنت‌های بازبینی روی GitHub؛ این‌جا به‌صورت متن): «تست برای تخفیف نداری» و «چه می‌شود اگر discount بیشتر از ۱۰۰ باشد؟». سارا فعلاً اولی را اصلاح می‌کند (دومی را بعد از انتشار به‌عنوان یک باگ واقعی می‌بینی). اصلاح = commit جدید روی همان شاخه، و PR به‌روز می‌شود:

Terminal window
cd ~/gitlab/sara
cat > tests/test_discount.py <<'EOF'
import unittest
from shop import total
class TestDiscount(unittest.TestCase):
def test_discount(self):
self.assertEqual(total([100], discount=10), 90.0)
if __name__ == "__main__":
unittest.main()
EOF
git add tests && git commit -qm "test(shop): cover percent discount (review feedback)"
git push -q 2>&1 | tail -1
git log --oneline main..feature/discount
خروجی
a9e555f test(shop): cover percent discount (review feedback)
50f2cc3 feat(shop): add percent discount to total

قدم ۵: CI سبز، ادغام با «دکمه‌ی merge»

Section titled “قدم ۵: CI سبز، ادغام با «دکمه‌ی merge»”

قبل از ادغام، CI روی شاخه‌ی PR اجرا می‌شود (روی یک کلون تمیز، مثل runner که از صفر checkout می‌کند). فقط اگر سبز بود، «دکمه‌ی merge» (merger) با squash ادغام می‌کند و شاخه را پاک می‌کند:

Terminal window
cd ~/gitlab && git clone -q server/shop.git merger && cd merger
git config user.name "GitHub Merge Button" && git config user.email "noreply@example.com"
echo "=== CI روی شاخه‌ی PR #1 (کلون تمیز):"
rm -rf /tmp/lx-ci && git clone -q -b feature/discount ~/gitlab/server/shop.git /tmp/lx-ci
(cd /tmp/lx-ci && python3 ~/mini-runner.py .github/workflows/ci.yml); echo "کد خروج CI: $?"
rm -rf /tmp/lx-ci
echo "=== تلاش مستقیم سارا برای push به main (بدون دکمه‌ی merge):"
cd ~/gitlab/sara && git switch -q main && git merge -q --ff-only origin/main 2>/dev/null
git merge -q --squash feature/discount && git commit -qm "feat(shop): add percent discount (#1)"
git push origin main 2>&1 | grep -E "error|rejected"
git reset -q --hard origin/main && git switch -q feature/discount
echo "=== «Squash and merge» توسط merger:"
cd ~/gitlab/merger
git merge -q --squash origin/feature/discount && git commit -qm "feat(shop): add percent discount (#1)"
LX_MERGE_BUTTON=1 git push -q origin main 2>&1 | tail -1
git push -q origin --delete feature/discount
git log --oneline
خروجی
=== CI روی شاخه‌ی PR #1 (کلون تمیز):
✓ CI سبز
کد خروج CI: 0
=== تلاش مستقیم سارا برای push به main (بدون دکمه‌ی merge):
Squash commit -- not updating HEAD
remote: error: main محافظت‌شده است. تغییر را با Pull Request بفرست.
! [remote rejected] main -> main (pre-receive hook declined)
error: failed to push some refs to '/home/ali/gitlab/server/shop.git'
=== «Squash and merge» توسط merger:
Squash commit -- not updating HEAD
59564da feat(shop): add percent discount (#1)
ee9dd31 ci: add test workflow, PR template, hooks and README
6259cd4 feat(shop): add total and its test
c1f8b99 chore: add .gitignore

سارا (بدون دکمه‌ی merge) نتوانست مستقیم به main بنویسد. merger بعد از CI سبز، commit های PR را به یک commit (squash) تبدیل و push کرد، و شاخه‌ی PR را حذف کرد (دکمه‌ی Delete branch).

قدم ۶: PR شماره‌ی ۲ و conflict

Section titled “قدم ۶: PR شماره‌ی ۲ و conflict”

حالا main جلو رفته و PR رضا (feature/tax) با آن تضاد دارد: هر دو تابع total را عوض کرده‌اند. GitHub می‌گوید «This branch has conflicts». رضا شاخه‌اش را با main به‌روز می‌کند (merge)، conflict را با ترکیب هر دو ویژگی حل می‌کند و تست‌ها را اجرا می‌کند:

Terminal window
cd ~/gitlab/reza
git fetch -q origin
echo "=== به‌روز کردن شاخه با main:"
git merge origin/main 2>&1 | tail -2
git status -s
echo "=== حل: هم تخفیف، هم مالیات"
cat > shop.py <<'EOF'
def total(prices, discount=0, tax=0):
"""Sum of item prices with optional percent discount and tax."""
amount = sum(prices) * (100 - discount) / 100
amount = amount * (100 + tax) / 100
return round(amount, 2)
EOF
git add shop.py && git commit -q --no-edit
python3 -m unittest discover -s tests 2>&1 | tail -3
git push -q 2>&1 | tail -1
git log --oneline --graph -6
خروجی
=== به‌روز کردن شاخه با main:
CONFLICT (content): Merge conflict in shop.py
Automatic merge failed; fix conflicts and then commit the result.
UU shop.py
A tests/test_discount.py
=== حل: هم تخفیف، هم مالیات
Ran 3 tests in 0.000s
OK
* 4ec06ee Merge remote-tracking branch 'origin/main' into feature/tax
|\
| * 59564da feat(shop): add percent discount (#1)
* | 88e5202 feat(shop): add percent tax to total
|/
* ee9dd31 ci: add test workflow, PR template, hooks and README
* 6259cd4 feat(shop): add total and its test
* c1f8b99 chore: add .gitignore

دقت کن بعد از ترکیب، همه‌ی تست‌ها (تخفیف از سارا و مالیات از رضا) با هم می‌گذرند؛ بدون اجرای تست بعد از حل conflict، ممکن بود کد «بدون علامت conflict» ولی غلط باشد. حالا CI و ادغام این PR (این بار با «Create a merge commit» برای تنوع):

Terminal window
cd ~/gitlab/merger
git fetch -q origin
rm -rf /tmp/lx-ci && git clone -q -b feature/tax ~/gitlab/server/shop.git /tmp/lx-ci
(cd /tmp/lx-ci && python3 ~/mini-runner.py .github/workflows/ci.yml); echo "کد خروج CI: $?"
rm -rf /tmp/lx-ci
git pull -q
git merge -q --no-ff -m "Merge pull request #2 from reza/feature/tax" origin/feature/tax
LX_MERGE_BUTTON=1 git push -q origin main 2>&1 | tail -1
git push -q origin --delete feature/tax
git log --oneline --graph
خروجی
✓ CI سبز
کد خروج CI: 0
* 544e19d Merge pull request #2 from reza/feature/tax
|\
| * 4ec06ee Merge remote-tracking branch 'origin/main' into feature/tax
| |\
| |/
|/|
* | 59564da feat(shop): add percent discount (#1)
| * 88e5202 feat(shop): add percent tax to total
|/
* ee9dd31 ci: add test workflow, PR template, hooks and README
* 6259cd4 feat(shop): add total and its test
* c1f8b99 chore: add .gitignore

merger نسخه را منتشر می‌کند: یادداشت انتشار را از روی commit های قراردادی می‌سازد، نسخه را tag می‌زند و tag را push می‌کند (tag ها از محافظت main رد نمی‌شوند؛ در GitHub هم قاعده‌ی tag جداست):

Terminal window
cd ~/gitlab/merger
echo "=== یادداشت انتشار از روی commit ها:"
for t in feat fix; do git log --format='%s' --no-merges | grep -E "^$t(\(.*\))?!?:" | sed -E "s/^$t(\(.*\))?!?: / [$t] /"; done
git tag -a v1.0.0 -m "Release 1.0.0: discount and tax"
git push -q origin v1.0.0
echo "=== tag های سرور:"
git ls-remote --tags origin | awk '{print $2}'
git describe
خروجی
=== یادداشت انتشار از روی commit ها:
[feat] add percent discount (#1)
[feat] add percent tax to total
[feat] add total and its test
=== tag های سرور:
refs/tags/v1.0.0
refs/tags/v1.0.0^{}
v1.0.0

قدم ۸: باگ بعد از انتشار، hotfix با cherry-pick

Section titled “قدم ۸: باگ بعد از انتشار، hotfix با cherry-pick”

یکی گزارش می‌دهد: total([100], discount=150) عدد منفی می‌دهد (همان نکته‌ای که بازبین در قدم ۴ پرسیده بود). main در این فاصله یک ویژگی نیمه‌تمام جدید هم دارد که نباید وارد نسخه‌ی منتشرشده شود. رفع باگ اول روی main (با PR) و بعد backport به نسخه‌ی انتشار با cherry-pick:

Terminal window
cd ~/gitlab/merger
echo "=== main یک ویژگی جدید (جدا) می‌گیرد:"
printf '\n\ndef shipping(weight):\n """Flat shipping fee per kilogram."""\n return round(weight * 2.5, 2)\n' >> shop.py
git commit -qam "feat(shop): add shipping fee"
LX_MERGE_BUTTON=1 git push -q origin main
echo "=== رضا باگ را در یک شاخه‌ی fix درست می‌کند (PR #3):"
cd ~/gitlab/reza
git switch -q main && git pull -q
git switch -qc fix/discount-range
sed -i 's|^ amount = sum(prices) \* (100 - discount) / 100| if not 0 <= discount <= 100:\n raise ValueError("discount must be between 0 and 100")\n&|' shop.py
cat > tests/test_range.py <<'EOF'
import unittest
from shop import total
class TestRange(unittest.TestCase):
def test_discount_out_of_range(self):
with self.assertRaises(ValueError):
total([100], discount=150)
if __name__ == "__main__":
unittest.main()
EOF
git add -A && git commit -qm "fix(shop): reject discounts outside 0-100"
git push -q -u origin fix/discount-range 2>&1 | tail -1
python3 -m unittest discover -s tests 2>&1 | tail -3
خروجی
=== main یک ویژگی جدید (جدا) می‌گیرد:
=== رضا باگ را در یک شاخه‌ی fix درست می‌کند (PR #3):
Ran 4 tests in 0.000s
OK

حالا merger رفع باگ را (بعد از CI) روی main ادغام می‌کند و همان یک commit را به شاخه‌ی انتشار می‌برد، تا نسخه‌ی v1.0.1 فقط باگ‌فیکس را داشته باشد، نه ویژگی نیمه‌تمام shipping:

Terminal window
cd ~/gitlab/merger
git fetch -q origin && git pull -q
git merge -q --ff-only origin/fix/discount-range
LX_MERGE_BUTTON=1 git push -q origin main 2>&1 | tail -1
git push -q origin --delete fix/discount-range
fix=$(git rev-parse --short HEAD)
echo "=== backport به نسخه‌ی انتشار:"
git switch -qc release/1.0 v1.0.0
git cherry-pick -x $fix 2>&1 | head -2
git tag -a v1.0.1 -m "Hotfix 1.0.1: reject discounts outside 0-100"
git push -q origin release/1.0 v1.0.1
echo "--- نسخه‌ی 1.0.1 تابع shipping را ندارد (فقط باگ‌فیکس):"
grep -c "def shipping" shop.py || true
echo "--- ولی main دارد:"
git show main:shop.py | grep -c "def shipping"
git log --oneline --graph --all --decorate -8
git switch -q main
خروجی
=== backport به نسخه‌ی انتشار:
Auto-merging shop.py
[release/1.0 12e9a1c] fix(shop): reject discounts outside 0-100
--- نسخه‌ی 1.0.1 تابع shipping را ندارد (فقط باگ‌فیکس):
0
--- ولی main دارد:
1
* f54a887 (origin/main, origin/HEAD, main) fix(shop): reject discounts outside 0-100
* 3375299 feat(shop): add shipping fee
| * 12e9a1c (HEAD -> release/1.0, tag: v1.0.1, origin/release/1.0) fix(shop): reject discounts outside 0-100
|/
* 544e19d (tag: v1.0.0) Merge pull request #2 from reza/feature/tax
|\
| * 4ec06ee Merge remote-tracking branch 'origin/main' into feature/tax
| |\
| |/
|/|
* | 59564da feat(shop): add percent discount (#1)
| * 88e5202 feat(shop): add percent tax to total
|/
* ee9dd31 ci: add test workflow, PR template, hooks and README

قدم ۹: شاخه‌ی پاک‌شده، نجات با reflog

Section titled “قدم ۹: شاخه‌ی پاک‌شده، نجات با reflog”

رضا روی یک شاخه‌ی تازه کار می‌کند و اشتباهاً آن را پاک می‌کند (-D) بدون اینکه push کرده باشد؛ reflog نجاتش می‌دهد:

Terminal window
cd ~/gitlab/reza
git switch -q main && git pull -q
git switch -qc feature/receipt
printf 'def receipt(lines):\n return "\\n".join(lines)\n' > receipt.py
git add receipt.py && git commit -qm "feat(shop): add receipt formatter"
git switch -q main
git branch -D feature/receipt
echo "--- reflog:"
git reflog | head -3
h=$(git reflog | grep -A1 "moving from feature/receipt to main" | tail -1 | awk '{print $1}')
git branch feature/receipt $h
git log --oneline feature/receipt -1
ls receipt.py 2>/dev/null || git show feature/receipt --stat --format='%s' | head -3
خروجی
Deleted branch feature/receipt (was 85a9bc0).
--- reflog:
f54a887 HEAD@{0}: checkout: moving from feature/receipt to main
85a9bc0 HEAD@{1}: commit: feat(shop): add receipt formatter
f54a887 HEAD@{2}: checkout: moving from main to feature/receipt
85a9bc0 feat(shop): add receipt formatter
feat(shop): add receipt formatter
receipt.py | 2 ++

قدم ۱۰: وضعیت نهایی پروژه

Section titled “قدم ۱۰: وضعیت نهایی پروژه”

آخرین کار، یک نگاه کلی: گراف، سهم هر نفر، tag ها و اینکه main سالم است:

Terminal window
cd ~/gitlab/merger
git switch -q main && git pull -q
echo "=== گراف:"
git log --oneline --graph --all --decorate -12
echo "=== سهم هر نفر (commit ها بدون merge):"
git shortlog -sn --no-merges main
echo "=== tag های انتشار:"
git tag -n
echo "=== سلامت main (CI روی کلون تمیز):"
rm -rf /tmp/lx-ci && git clone -q ~/gitlab/server/shop.git /tmp/lx-ci
(cd /tmp/lx-ci && python3 ~/mini-runner.py .github/workflows/ci.yml); rm -rf /tmp/lx-ci
echo "=== بررسی سلامت مخزن:"
git fsck --no-dangling && echo "fsck: مشکلی نیست"
خروجی
=== گراف:
* f54a887 (HEAD -> main, origin/main, origin/HEAD) fix(shop): reject discounts outside 0-100
* 3375299 feat(shop): add shipping fee
| * 12e9a1c (tag: v1.0.1, origin/release/1.0, release/1.0) fix(shop): reject discounts outside 0-100
|/
* 544e19d (tag: v1.0.0) Merge pull request #2 from reza/feature/tax
|\
| * 4ec06ee Merge remote-tracking branch 'origin/main' into feature/tax
| |\
| |/
|/|
* | 59564da feat(shop): add percent discount (#1)
| * 88e5202 feat(shop): add percent tax to total
|/
* ee9dd31 ci: add test workflow, PR template, hooks and README
* 6259cd4 feat(shop): add total and its test
* c1f8b99 chore: add .gitignore
=== سهم هر نفر (commit ها بدون merge):
3 Sara
2 GitHub Merge Button
2 Reza
=== tag های انتشار:
v1.0.0 Release 1.0.0: discount and tax
v1.0.1 Hotfix 1.0.1: reject discounts outside 0-100
=== سلامت main (CI روی کلون تمیز):
✓ CI سبز
=== بررسی سلامت مخزن:
fsck: مشکلی نیست

چه چیزهایی به کار بردی؟

Section titled “چه چیزهایی به کار بردی؟”
✓ چک‌لیست

پشت پرده: این شبیه‌سازی چقدر به GitHub واقعی نزدیک است؟

Section titled “پشت پرده: این شبیه‌سازی چقدر به GitHub واقعی نزدیک است؟”
در این درس در GitHub واقعی
مخزن bare محلی مخزن در GitHub (همان Git)
hook pre-receive و LX_MERGE_BUTTON Branch protection / Ruleset + دکمه‌ی Merge
«PR» = شاخه‌ی push‌شده و git diff main... Pull Request با گفت‌وگو و Files changed
کامنت بازبینی = متن کامنت‌ها، Approve، Request changes
mini-runner روی کلون تمیز GitHub Actions روی runner و status check اجباری
merge --squash، --no-ff دکمه‌ی Squash and merge / Create a merge commit
git push origin --delete دکمه‌ی Delete branch

همه‌ی Git در این درس واقعی است (شاخه، conflict، rebase/merge، cherry-pick، tag، reflog)؛ آنچه شبیه‌سازی شده فقط لایه‌ی همکاری GitHub است که در درس‌های قبل (Pull Request، Actions، قراردادها) با جزئیات و برچسب «نمونه» توضیح داده شد.

قدم ۲ و ۵: main محافظت‌شده است. راه‌حل: همیشه شاخه‌ی ویژگی + PR.

۲) نزدن تست بعد از حل conflict

Section titled “۲) نزدن تست بعد از حل conflict”

کدی که علامت conflict ندارد هنوز ممکن است غلط ترکیب شده باشد (قدم ۶). راه‌حل: بعد از هر حل، تست‌ها (و CI) را اجرا کن.

۳) backport با merge به‌جای cherry-pick

Section titled “۳) backport با merge به‌جای cherry-pick”

برای رساندن یک رفع باگ به نسخه‌ی منتشرشده، ادغام کل main در شاخه‌ی انتشار، همه‌ی ویژگی‌های نیمه‌تمام را هم وارد می‌کند. ببین چه می‌شود:

Terminal window
cd ~/gitlab/merger
git switch -q -c wrong-backport v1.0.0
git merge -q --no-edit main
echo "--- «نسخه‌ی انتشار» حالا تابع shipping (ویژگی نیمه‌تمام) را هم دارد:"
grep -c "def shipping" shop.py
git switch -q main && git branch -D wrong-backport -q
خروجی
--- «نسخه‌ی انتشار» حالا تابع shipping (ویژگی نیمه‌تمام) را هم دارد:
1

راه‌حل: فقط commit رفع باگ را cherry-pick -x کن (قدم ۸).

۴) tag زدن قبل از سبز شدن CI

Section titled “۴) tag زدن قبل از سبز شدن CI”

tag منتشرشده تغییرناپذیر است (درس tag). راه‌حل: اول CI روی main سبز، بعد tag.

۵) پاک‌کردن شاخه‌ی ادغام‌نشده

Section titled “۵) پاک‌کردن شاخه‌ی ادغام‌نشده”

git branch -D یعنی بی‌پرسش؛ قدم ۹: reflog نجات داد، ولی همیشه -d (امن) را بزن.

✎ تمرینآسان

بعد از اجرای پروژه، با یک دستور فهرست commit های main را با شکل گراف ببین و تعداد commit های هر نفر را بشمار.

دیدن جواب
Terminal window
cd ~/gitlab/merger
git log --oneline --graph main | head -8
git shortlog -sn --no-merges main
خروجی
* f54a887 fix(shop): reject discounts outside 0-100
* 3375299 feat(shop): add shipping fee
* 544e19d Merge pull request #2 from reza/feature/tax
|\
| * 4ec06ee Merge remote-tracking branch 'origin/main' into feature/tax
| |\
| |/
|/|
3 Sara
2 GitHub Merge Button
2 Reza
✎ تمرینمتوسط

تمرین اصلی: با دو کلون جدا نقش دو برنامه‌نویس را بازی کن. یک مخزن bare با دو کلون ana و ben بساز؛ هر دو روی یک فایل دو شاخه‌ی مختلف را تغییر می‌دهند، ana اول به main ادغام می‌کند و ben (با conflict) شاخه‌اش را به‌روز و حل و ادغام می‌کند. ثابت کن main در پایان هر دو تغییر را دارد.

دیدن جواب
Terminal window
mkdir -p ~/gitlab/ex2 && cd ~/gitlab/ex2
git init -q --bare srv.git
git clone -q srv.git ana 2>/dev/null; git clone -q srv.git ben 2>/dev/null
for w in ana ben; do (cd $w && git config user.name "$w" && git config user.email "$w@example.com"); done
cd ana && printf 'title: App\nversion: 1\n' > cfg && git add cfg && git commit -qm "chore: init" && git push -q origin main
cd ../ben && git pull -q
echo "--- هر دو یک خط را روی شاخه‌ی خودشان عوض می‌کنند:"
cd ../ana && git switch -qc feature/a && sed -i 's/version: 1/version: 2 (ana)/' cfg && git commit -qam "feat: ana bumps version" && git push -q -u origin feature/a
cd ../ben && git switch -qc feature/b && sed -i 's/version: 1/version: 2 (ben)/' cfg && git commit -qam "feat: ben bumps version" && git push -q -u origin feature/b
echo "--- ana اول ادغام می‌کند (fast-forward) و main را push می‌کند:"
cd ../ana && git switch -q main && git merge -q --ff-only feature/a && git push -q origin main
echo "--- ben: به‌روز کردن شاخه و conflict:"
cd ../ben && git fetch -q && git merge origin/main 2>&1 | tail -1
printf 'title: App\nversion: 2 (ana + ben)\n' > cfg && git add cfg && git commit -q --no-edit
git switch -q main && git pull -q && git merge -q --ff-only feature/b && git push -q origin main
echo "--- نتیجه:"
git log --oneline --graph; cat cfg
خروجی
--- هر دو یک خط را روی شاخه‌ی خودشان عوض می‌کنند:
--- ana اول ادغام می‌کند (fast-forward) و main را push می‌کند:
--- ben: به‌روز کردن شاخه و conflict:
Automatic merge failed; fix conflicts and then commit the result.
--- نتیجه:
* 77cff31 Merge remote-tracking branch 'origin/main' into feature/b
|\
| * e0e902d feat: ana bumps version
* | 75599a0 feat: ben bumps version
|/
* fed5f56 chore: init
title: App
version: 2 (ana + ben)
✎ تمرینسخت

به پروژه‌ی اصلی این درس یک رگرسیون اضافه کن و پیدایش کن: روی main چند commit بزن که یکی از آن‌ها (بدون اشاره‌ی پیام) تابع total را اشتباه می‌کند، و با git bisect run و یک اسکریپت تست (که از python3 -m unittest استفاده می‌کند) commit خراب را خودکار پیدا کن. ثابت کن شناسه‌ی پیداشده همان است.

دیدن جواب
Terminal window
cd ~/gitlab/merger
git switch -q main && git pull -q
good=$(git rev-parse --short HEAD)
for i in 1 2 3; do echo "# note $i" >> README.md; git commit -qam "docs: note $i"; done
sed -i 's/amount \* (100 + tax) \/ 100/amount * (100 - tax) \/ 100/' shop.py && git commit -qam "refactor(shop): simplify tax handling"
bad=$(git rev-parse --short HEAD)
for i in 4 5 6; do echo "# note $i" >> README.md; git commit -qam "docs: note $i"; done
cat > /tmp/lx-test.sh <<'EOF'
#!/bin/sh
python3 -B -m unittest discover -s tests > /dev/null 2>&1
EOF
chmod +x /tmp/lx-test.sh
git bisect start HEAD $good > /dev/null
git bisect run /tmp/lx-test.sh 2>&1 | grep -E "first bad commit"
found=$(git rev-parse --short refs/bisect/bad)
git bisect reset > /dev/null 2>&1
echo "مقصر واقعی: $bad ؛ bisect: $found"
[ "$bad" = "$found" ] && echo "✓ درست پیدا شد"
git reset -q --hard $good
خروجی
68fd79f4a70383fb41c41fde9713e5cadea32b6d is the first bad commit
bisect found first bad commit
مقصر واقعی: 68fd79f ؛ bisect: 68fd79f
✓ درست پیدا شد
⚡ بررسی سریع

در این پروژه چرا main را فقط «دکمه‌ی merge» تغییر می‌دهد و نه خود برنامه‌نویس‌ها؟

؟ آزمونک
  1. conflict بین PR رضا و main را چه کسی و کجا حل می‌کند؟

  2. هدف backport با cherry-pick -x در قدم ۸ چه بود؟

  3. چرا بعد از حل conflict حتماً تست‌ها را اجرا می‌کنیم؟

  4. شاخه‌ی feature/receipt را اشتباهاً با -D پاک کرده‌ای و push نشده بود. راه نجات؟

  5. در این درس «شبیه‌سازی» چه بود و «واقعی» چه؟

  • چرخه‌ی کار تیمی: مخزن با CI و قاعده‌ها ← شاخه برای هر ویژگی (feature/…، fix/…) ← commit های قراردادی ← PR و بازبینی (git diff main...) ← CI سبز ← ادغام (squash یا merge commit) توسط «دکمه‌ی merge» ← main همیشه سالم.
  • conflict: در شاخه‌ی خودت با main به‌روز کن، حل کن، تست را اجرا کن، push کن.
  • انتشار: tag annotated (v1.0.0) و git push origin tag؛ یادداشت انتشار از روی commit های قراردادی. hotfix: رفع روی main، backport با cherry-pick -x روی release/x.y از tag و v1.0.1.
  • نجات: git reflog ← git branch نام هش. یافتن رگرسیون: git bisect run.
  • در پروژه‌ی واقعی: همین قدم‌ها روی GitHub (Branch protection، Actions، Pull Request) با همین Git.
برگه‌ی تقلب این درس
دستورکاری که می‌کند
git switch -c feature/نامشاخه‌ی ویژگی
git push -u origin feature/نامارسال و upstream
git diff main...origin/feature/نامFiles changed (PR)
git merge origin/main (در شاخه‌ی PR)رفع conflict PR
git merge --squash / --no-ffمعادل Squash / Merge commit
git tag -a v1.0.0 -m "..." && git push origin v1.0.0انتشار
git switch -c release/1.0 v1.0.0شاخه‌ی انتشار
git cherry-pick -x هشbackport رفع باگ
git reflog → git branch نام هشنجات شاخه‌ی پاک‌شده
git bisect run ./test.shیافتن commit خراب
git log --oneline --graph --all --decorateنگاه کلی