توی این درس یاد میگیری همهی دوره را در یک پروژهی واقعی کنار هم بگذاری: با دو کلون جدا نقش دو برنامهنویس (سارا و رضا) را بازی میکنی و چرخهی کامل کار تیمی را اجرا میکنی: راهاندازی مخزن با CI و hook ها، شاخه برای هر ویژگی با commit های قراردادی، Pull Request و بازبینی کد، یک conflict واقعی، ادغام با «دکمهی merge» روی main محافظتشده، انتشار با tag، یک hotfix با cherry-pick، و نجات یک شاخهی پاکشده با reflog. تمرین: با دو کلون جدا نقش دو برنامهنویس را بازی کن.
مسئله: از دانستن دستورها تا کار کردن با تیم
Section titled “مسئله: از دانستن دستورها تا کار کردن با تیم”تکتک دستورهای Git را یاد گرفتی. ولی کار تیمی یعنی ترتیب و هماهنگی: چه کسی کجا commit میزند، چه چیزی با PR میرود، conflict را چه کسی حل میکند، چه زمانی tag میخورد. این درس همان ترتیب را روی یک پروژهی کوچک (یک ماشینحساب فروشگاه) اجرا میکند.
نقشهی پروژه
Section titled “نقشهی پروژه”| نقش | چه میکند |
|---|---|
سارا (~/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 پشت صحنه اعمال میکند.
قدمبهقدم
Section titled “قدمبهقدم”قدم ۰: سرور محافظتشده و ابزار «CI»
Section titled “قدم ۰: سرور محافظتشده و ابزار «CI»”اول «سرور». hook pre-receive فقط وقتی اجازهی نوشتن روی main میدهد که از «دکمهی merge» بیاید (در این شبیهسازی: متغیر LX_MERGE_BUTTON=1؛ چون سرور محلی است، متغیر محیطی به hook میرسد). ساخت main برای بار اول و کار روی شاخههای دیگر آزاد است:
mkdir -p ~/gitlab/server && cd ~/gitlab/servergit init -q --bare shop.gitcat > shop.git/hooks/pre-receive <<'EOF'#!/bin/sh# شبیهسازی «محافظت از main»: فقط دکمهی merge میتواند main را عوض کندzero=0000000000000000000000000000000000000000while 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 fidoneEOFchmod +x shop.git/hooks/pre-receiveecho "سرور آماده است:"; ls shop.git/hooks/pre-receiveسرور آماده است:shop.git/hooks/pre-receiveو ابزار آموزشی «CI» که از درس GitHub Actions میشناسی: مرحلههای run یک workflow را اجرا میکند. (این جای runner واقعی GitHub است؛ فقط برای این شبیهسازی.)
cat > ~/mini-runner.py <<'EOF'import subprocess, sys, yaml
wf = yaml.safe_load(open(sys.argv[1]))ok = Truefor 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 breakprint("✓ CI سبز" if ok else "✗ CI قرمز")sys.exit(0 if ok else 1)EOFecho "mini-runner آماده است"mini-runner آماده استقدم ۱: سارا پروژه را راه میاندازد
Section titled “قدم ۱: سارا پروژه را راه میاندازد”سارا یک مخزن با کد، تست، .gitignore، قاعدهی پیام commit (hook در یک پوشهی داخل مخزن)، workflow ی CI و قالب PR میسازد، با commit های قراردادی (chore:، feat:، test:، ci:، docs:):
mkdir -p ~/gitlab/sara && cd ~/gitlab/sara && git init -qgit config user.name "Sara" && git config user.email "sara@example.com"git remote add origin ~/gitlab/server/shop.gitmkdir -p tests .githooks .github/workflows
cat > .gitignore <<'EOF'__pycache__/*.pyc.envEOFgit add .gitignore && git commit -qm "chore: add .gitignore"
cat > shop.py <<'EOF'def total(prices): """Sum of item prices.""" return round(sum(prices), 2)EOFcat > tests/test_shop.py <<'EOF'import unittestfrom shop import total
class TestTotal(unittest.TestCase): def test_total(self): self.assertEqual(total([10, 5.5]), 15.5)
if __name__ == "__main__": unittest.main()EOFgit 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 ;; esacpattern='^(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; }EOFchmod +x .githooks/commit-msgcat > .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 testsEOFprintf '## What changed?\n\n## How did you test it?\n' > .github/PULL_REQUEST_TEMPLATE.mdcat > 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.EOFecho "--- بررسی workflow با actionlint و اجرای محلی تستها:"actionlint -color=false; echo "actionlint کد خروج: $?"python3 -m unittest discover -s tests 2>&1 | tail -3git config core.hooksPath .githooksgit add -A && git commit -qm "ci: add test workflow, PR template, hooks and README"git push -q -u origin main 2>&1 | tail -1git log --oneline--- بررسی workflow با actionlint و اجرای محلی تستها:actionlint کد خروج: 0Ran 1 test in 0.000s
OKee9dd31 ci: add test workflow, PR template, hooks and README6259cd4 feat(shop): add total and its testc1f8b99 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، و نسخهی خوب:
cd ~/gitlab && git clone -q server/shop.git reza && cd rezagit config user.name "Reza" && git config user.email "reza@example.com"git config core.hooksPath .githooksecho "=== ۱) commit با پیام بد:"echo "# note" >> README.mdgit commit -qam "updated stuff" 2>&1 | head -1echo "=== ۲) 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/mainecho "(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 را عمداً پیش میآوریم). سارا اول فقط کد را میفرستد:
cd ~/gitlab/saragit switch -qc feature/discountcat > 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)EOFgit commit -qam "feat(shop): add percent discount to total"git push -q -u origin feature/discount 2>&1 | tail -1echo "--- سارا: feature/discount push شد"
cd ~/gitlab/rezagit switch -qc feature/taxcat > 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)EOFcat > tests/test_tax.py <<'EOF'import unittestfrom shop import total
class TestTax(unittest.TestCase): def test_tax(self): self.assertEqual(total([100], tax=9), 109.0)
if __name__ == "__main__": unittest.main()EOFgit add -A && git commit -qm "feat(shop): add percent tax to total"git push -q -u origin feature/tax 2>&1 | tail -1echo "--- رضا: 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 سهنقطهای میبیند و آن را اجرا میکند:
cd ~/gitlab/rezagit fetch -q originecho "=== commit های PR #1 (feature/discount):"git log --oneline main..origin/feature/discountecho "=== Files changed:"git diff --stat main...origin/feature/discountecho "=== اجرا و آزمایش روی شاخهی PR:"git switch -q --detach origin/feature/discountpython3 -c "from shop import total; print('total([100], 10) =', total([100], 10))"python3 -m unittest discover -s tests 2>&1 | tail -3git 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.0Ran 1 test in 0.000s
OKبازبین دو نکته میبیند (کامنتهای بازبینی روی GitHub؛ اینجا بهصورت متن): «تست برای تخفیف نداری» و «چه میشود اگر discount بیشتر از ۱۰۰ باشد؟». سارا فعلاً اولی را اصلاح میکند (دومی را بعد از انتشار بهعنوان یک باگ واقعی میبینی). اصلاح = commit جدید روی همان شاخه، و PR بهروز میشود:
cd ~/gitlab/saracat > tests/test_discount.py <<'EOF'import unittestfrom shop import total
class TestDiscount(unittest.TestCase): def test_discount(self): self.assertEqual(total([100], discount=10), 90.0)
if __name__ == "__main__": unittest.main()EOFgit add tests && git commit -qm "test(shop): cover percent discount (review feedback)"git push -q 2>&1 | tail -1git log --oneline main..feature/discounta9e555f 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 ادغام میکند و شاخه را پاک میکند:
cd ~/gitlab && git clone -q server/shop.git merger && cd mergergit 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-ciecho "=== تلاش مستقیم سارا برای push به main (بدون دکمهی merge):"cd ~/gitlab/sara && git switch -q main && git merge -q --ff-only origin/main 2>/dev/nullgit 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/discountecho "=== «Squash and merge» توسط merger:"cd ~/gitlab/mergergit 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 -1git push -q origin --delete feature/discountgit log --oneline=== CI روی شاخهی PR #1 (کلون تمیز):✓ CI سبزکد خروج CI: 0=== تلاش مستقیم سارا برای push به main (بدون دکمهی merge):Squash commit -- not updating HEADremote: 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 HEAD59564da feat(shop): add percent discount (#1)ee9dd31 ci: add test workflow, PR template, hooks and README6259cd4 feat(shop): add total and its testc1f8b99 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 را با ترکیب هر دو ویژگی حل میکند و تستها را اجرا میکند:
cd ~/gitlab/rezagit fetch -q originecho "=== بهروز کردن شاخه با main:"git merge origin/main 2>&1 | tail -2git status -secho "=== حل: هم تخفیف، هم مالیات"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)EOFgit add shop.py && git commit -q --no-editpython3 -m unittest discover -s tests 2>&1 | tail -3git push -q 2>&1 | tail -1git log --oneline --graph -6=== بهروز کردن شاخه با main:CONFLICT (content): Merge conflict in shop.pyAutomatic merge failed; fix conflicts and then commit the result.UU shop.pyA 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» برای تنوع):
cd ~/gitlab/mergergit fetch -q originrm -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-cigit pull -qgit merge -q --no-ff -m "Merge pull request #2 from reza/feature/tax" origin/feature/taxLX_MERGE_BUTTON=1 git push -q origin main 2>&1 | tail -1git push -q origin --delete feature/taxgit 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قدم ۷: انتشار، v1.0.0
Section titled “قدم ۷: انتشار، v1.0.0”merger نسخه را منتشر میکند: یادداشت انتشار را از روی commit های قراردادی میسازد، نسخه را tag میزند و tag را push میکند (tag ها از محافظت main رد نمیشوند؛ در GitHub هم قاعدهی tag جداست):
cd ~/gitlab/mergerecho "=== یادداشت انتشار از روی commit ها:"for t in feat fix; do git log --format='%s' --no-merges | grep -E "^$t(\(.*\))?!?:" | sed -E "s/^$t(\(.*\))?!?: / [$t] /"; donegit tag -a v1.0.0 -m "Release 1.0.0: discount and tax"git push -q origin v1.0.0echo "=== 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.0refs/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:
cd ~/gitlab/mergerecho "=== main یک ویژگی جدید (جدا) میگیرد:"printf '\n\ndef shipping(weight):\n """Flat shipping fee per kilogram."""\n return round(weight * 2.5, 2)\n' >> shop.pygit commit -qam "feat(shop): add shipping fee"LX_MERGE_BUTTON=1 git push -q origin main
echo "=== رضا باگ را در یک شاخهی fix درست میکند (PR #3):"cd ~/gitlab/rezagit switch -q main && git pull -qgit switch -qc fix/discount-rangesed -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.pycat > tests/test_range.py <<'EOF'import unittestfrom 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()EOFgit add -A && git commit -qm "fix(shop): reject discounts outside 0-100"git push -q -u origin fix/discount-range 2>&1 | tail -1python3 -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:
cd ~/gitlab/mergergit fetch -q origin && git pull -qgit merge -q --ff-only origin/fix/discount-rangeLX_MERGE_BUTTON=1 git push -q origin main 2>&1 | tail -1git push -q origin --delete fix/discount-rangefix=$(git rev-parse --short HEAD)echo "=== backport به نسخهی انتشار:"git switch -qc release/1.0 v1.0.0git cherry-pick -x $fix 2>&1 | head -2git tag -a v1.0.1 -m "Hotfix 1.0.1: reject discounts outside 0-100"git push -q origin release/1.0 v1.0.1echo "--- نسخهی 1.0.1 تابع shipping را ندارد (فقط باگفیکس):"grep -c "def shipping" shop.py || trueecho "--- ولی main دارد:"git show main:shop.py | grep -c "def shipping"git log --oneline --graph --all --decorate -8git 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 نجاتش میدهد:
cd ~/gitlab/rezagit switch -q main && git pull -qgit switch -qc feature/receiptprintf 'def receipt(lines):\n return "\\n".join(lines)\n' > receipt.pygit add receipt.py && git commit -qm "feat(shop): add receipt formatter"git switch -q maingit branch -D feature/receiptecho "--- reflog:"git reflog | head -3h=$(git reflog | grep -A1 "moving from feature/receipt to main" | tail -1 | awk '{print $1}')git branch feature/receipt $hgit log --oneline feature/receipt -1ls receipt.py 2>/dev/null || git show feature/receipt --stat --format='%s' | head -3Deleted branch feature/receipt (was 85a9bc0).--- reflog:f54a887 HEAD@{0}: checkout: moving from feature/receipt to main85a9bc0 HEAD@{1}: commit: feat(shop): add receipt formatterf54a887 HEAD@{2}: checkout: moving from main to feature/receipt85a9bc0 feat(shop): add receipt formatterfeat(shop): add receipt formatter
receipt.py | 2 ++قدم ۱۰: وضعیت نهایی پروژه
Section titled “قدم ۱۰: وضعیت نهایی پروژه”آخرین کار، یک نگاه کلی: گراف، سهم هر نفر، tag ها و اینکه main سالم است:
cd ~/gitlab/mergergit switch -q main && git pull -qecho "=== گراف:"git log --oneline --graph --all --decorate -12echo "=== سهم هر نفر (commit ها بدون merge):"git shortlog -sn --no-merges mainecho "=== tag های انتشار:"git tag -necho "=== سلامت 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-ciecho "=== بررسی سلامت مخزن:"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 taxv1.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، قراردادها) با جزئیات و برچسب «نمونه» توضیح داده شد.
اشتباهات رایج
Section titled “اشتباهات رایج”۱) push مستقیم به main
Section titled “۱) push مستقیم به main”قدم ۲ و ۵: main محافظتشده است. راهحل: همیشه شاخهی ویژگی + PR.
۲) نزدن تست بعد از حل conflict
Section titled “۲) نزدن تست بعد از حل conflict”کدی که علامت conflict ندارد هنوز ممکن است غلط ترکیب شده باشد (قدم ۶). راهحل: بعد از هر حل، تستها (و CI) را اجرا کن.
۳) backport با merge بهجای cherry-pick
Section titled “۳) backport با merge بهجای cherry-pick”برای رساندن یک رفع باگ به نسخهی منتشرشده، ادغام کل main در شاخهی انتشار، همهی ویژگیهای نیمهتمام را هم وارد میکند. ببین چه میشود:
cd ~/gitlab/mergergit switch -q -c wrong-backport v1.0.0git merge -q --no-edit mainecho "--- «نسخهی انتشار» حالا تابع shipping (ویژگی نیمهتمام) را هم دارد:"grep -c "def shipping" shop.pygit 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 های هر نفر را بشمار.
دیدن جواب
cd ~/gitlab/mergergit log --oneline --graph main | head -8git 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 در پایان هر دو تغییر را دارد.
دیدن جواب
mkdir -p ~/gitlab/ex2 && cd ~/gitlab/ex2git init -q --bare srv.gitgit clone -q srv.git ana 2>/dev/null; git clone -q srv.git ben 2>/dev/nullfor w in ana ben; do (cd $w && git config user.name "$w" && git config user.email "$w@example.com"); donecd ana && printf 'title: App\nversion: 1\n' > cfg && git add cfg && git commit -qm "chore: init" && git push -q origin maincd ../ben && git pull -qecho "--- هر دو یک خط را روی شاخهی خودشان عوض میکنند:"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/acd ../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/becho "--- ana اول ادغام میکند (fast-forward) و main را push میکند:"cd ../ana && git switch -q main && git merge -q --ff-only feature/a && git push -q origin mainecho "--- ben: بهروز کردن شاخه و conflict:"cd ../ben && git fetch -q && git merge origin/main 2>&1 | tail -1printf 'title: App\nversion: 2 (ana + ben)\n' > cfg && git add cfg && git commit -q --no-editgit switch -q main && git pull -q && git merge -q --ff-only feature/b && git push -q origin mainecho "--- نتیجه:"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: inittitle: Appversion: 2 (ana + ben)به پروژهی اصلی این درس یک رگرسیون اضافه کن و پیدایش کن: روی main چند commit بزن که یکی از آنها (بدون اشارهی پیام) تابع total را اشتباه میکند، و با git bisect run و یک اسکریپت تست (که از python3 -m unittest استفاده میکند) commit خراب را خودکار پیدا کن. ثابت کن شناسهی پیداشده همان است.
دیدن جواب
cd ~/gitlab/mergergit switch -q main && git pull -qgood=$(git rev-parse --short HEAD)for i in 1 2 3; do echo "# note $i" >> README.md; git commit -qam "docs: note $i"; donesed -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"; donecat > /tmp/lx-test.sh <<'EOF'#!/bin/shpython3 -B -m unittest discover -s tests > /dev/null 2>&1EOFchmod +x /tmp/lx-test.shgit bisect start HEAD $good > /dev/nullgit 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>&1echo "مقصر واقعی: $bad ؛ bisect: $found"[ "$bad" = "$found" ] && echo "✓ درست پیدا شد"git reset -q --hard $good68fd79f4a70383fb41c41fde9713e5cadea32b6d is the first bad commitbisect found first bad commitمقصر واقعی: 68fd79f ؛ bisect: 68fd79f✓ درست پیدا شدآزمونک
Section titled “آزمونک”در این پروژه چرا main را فقط «دکمهی merge» تغییر میدهد و نه خود برنامهنویسها؟
این همان اصل GitHub Flow است: main همیشه سالم، هر کار روی شاخهی جدا، ادغام فقط با PR، بازبینی و تستها.
conflict بین PR رضا و main را چه کسی و کجا حل میکند؟
و بعد از حل، حتماً تستها را اجرا میکند.
هدف backport با cherry-pick -x در قدم ۸ چه بود؟
نسخهی v1.0.1 فقط باگفیکس دارد.
چرا بعد از حل conflict حتماً تستها را اجرا میکنیم؟
تست ترکیب تخفیف و مالیات را امتحان کرد.
شاخهی feature/receipt را اشتباهاً با -D پاک کردهای و push نشده بود. راه نجات؟
تا وقتی gc اجرا نشده یا reflog منقضی نشده.
در این درس «شبیهسازی» چه بود و «واقعی» چه؟
جزئیات در بخش «پشت پرده».
جمعبندی
Section titled “جمعبندی”- چرخهی کار تیمی: مخزن با 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 | نگاه کلی |