در این فصل چه یاد میگیری#
یک کار را دو بار در مخزن ثبت میکنیم: یک بار در هشت commitِ ریز، یک بار در دو commitِ درشت. درختِ نهایی بایتبهبایت یکی است — همان فایلها، همان محتوا، همان کار.
بعد بازبینی را با بودجهٔ ثابتِ چهل خط روی چهار چیدمان میسنجیم — همان یک کار، در ۱ و ۲ و ۴ و ۸ commit. وقتی کلِ کار یک commit باشد، ۱۵٫۴٪ از خطهای افزوده جلوی چشمِ بازبین میآید؛ با هشت commit، ۷۱٫۲٪. با شش خطا که تصادفی روی همان diffهای واقعی پخش شدهاند، همان دو سرِ طیف ۰٫۹۶ در برابرِ ۴٫۱۷ خطا پیدا میکنند.
و در آخر یک خطا از هر دو بازبینی رد میشود و به تولید میرسد. git bisect در تاریخچهٔ ریز مقصر را در یک diffِ سهخطی تحویل میدهد و در تاریخچهٔ درشت در یک diffِ ۱۵۷ خطی.

آخر این فصل میتوانی:
- یک کار را به تغییرهای کوچکِ مستقل بشکنی و بگویی چرا اندازهٔ commit یک تصمیمِ مهندسی است
- سهمِ خواندهشدهٔ یک diff را اندازه بگیری، بهجای اینکه دربارهٔ کیفیتِ بازبینی حدس بزنی
- با
git bisectمقصرِ یک خرابی را پیدا کنی و هزینهٔ پیدا کردنش را بشماری - پیامِ commit را طوری بنویسی که سالِ بعد جوابِ «چرا» را بدهد
قبل از شروع#
از ترمِ ۱ فصلِ ۲ و ۹: مجموعهٔ تست، و بستهای که آن را به یک اسکریپتِ کدِ خروجدار تبدیل کرد (sys.exit در tests.py). همان قرارداد اینجا به git bisect غذا میدهد.
از ترمِ ۲ فصلِ ۳: قراردادِ اسکیما. یکی از هشت تغییرِ این فصل همان است، و فصلِ بعد رویش دروازه میگذارد.
این تنها فصلِ دوره است که به git بهعنوان یک برنامهٔ بیرونی نیاز دارد. در Colab نصب است و لازم نیست کاری بکنی. همهٔ کار روی یک مخزنِ دورریختنی در پوشهٔ موقت انجام میشود — هیچکدام از این دستورها به مخزنِ واقعیِ تو دست نمیزند.
📓 نوتبوک: نوتبوک این فصل را در Colab باز کن — همهٔ کدهای این فصل آماده و بهترتیب داخلش هست.
| اجرا | زمانِ تقریبی |
|---|---|
| کلِ نوتبوک روی CPU | حدودِ یک دقیقه |
| GPU | لازم نیست |
۱. یک کار، دو تاریخچه#
کارِ این هفته هشت تغییر است: پاکسازیِ متن، قراردادِ اسکیما، چهار فایلِ نمونهٔ طلایی، یک سرویس، و یک بازنویسیِ کوچک برای سرعت. هر هشتتا واقعیاند و از همان پیکرهٔ ترم ساخته میشوند.
import random
CLEAN_OK = '''"""clean.py — پاکسازیِ متنِ تیکت، پیش از هر کارِ دیگر."""
def normalise(text):
return " ".join(text.replace("ي", "ی").replace("ك", "ک").split())
'''
CLEAN_FAST = '''"""clean.py — پاکسازیِ متنِ تیکت، پیش از هر کارِ دیگر."""
TABLE = str.maketrans({"ي": "ی"})
def normalise(text):
return " ".join(text.translate(TABLE).split())
'''
SCHEMA = '''"""schema.py — قراردادِ ستونهای ورودی (ترمِ ۲ فصلِ ۳)."""
REQUIRED = ("ticket_id", "customer_id", "week", "channel", "category", "text")
CATEGORIES = ("پرداخت", "ارسال", "حساب کاربری", "اپلیکیشن")
def validate(row):
for column in REQUIRED:
if column not in row:
raise KeyError(column)
if row["category"] not in CATEGORIES:
raise ValueError(row["category"])
return row
'''
SERVE = '''"""serve.py — تابعِ پیشبینی روی نمونههای طلایی."""
import clean
import golden_payment
def predict(text):
words = set(clean.normalise(text).split())
return "پرداخت" if "پرداخت" in words else "نامعلوم"
'''
def golden(tag, category, rows):
"""نمونههای طلاییِ یک دسته — جایی که پروژههای واقعی خط جمع میکنند."""
picked = [t for t in TICKETS if t["category"] == category][:rows]
body = [f'"""golden_{tag}.py — نمونههای طلاییِ دستهٔ {category}."""', "", "GOLDEN = ["]
body += [f' ("{t["ticket_id"]}", "{category}"),' for t in picked]
return "\n".join(body + ["]", ""]) + "\n"
CHANGES = [
("pipeline/clean.py", CLEAN_OK, "پاکسازیِ متن پیش از هر کار", ""),
("pipeline/schema.py", SCHEMA, "قراردادِ ستونهای ورودی", ""),
("pipeline/golden_payment.py", golden("payment", "پرداخت", 30),
"نمونههای طلاییِ دستهٔ پرداخت", ""),
("pipeline/golden_shipping.py", golden("shipping", "ارسال", 45),
"نمونههای طلاییِ دستهٔ ارسال", ""),
("pipeline/golden_account.py", golden("account", "حساب کاربری", 60),
"نمونههای طلاییِ دستهٔ حساب کاربری", ""),
("pipeline/golden_app.py", golden("app", "اپلیکیشن", 75),
"نمونههای طلاییِ دستهٔ اپلیکیشن", ""),
("pipeline/serve.py", SERVE, "سرویس: تابعِ پیشبینی", ""),
("pipeline/clean.py", CLEAN_FAST, "پاکسازی با translate، برای سرعت",
"زنجیرهٔ replace برای هر تیکت دو رشتهٔ تازه میسازد و translate یکی."
" روی نمونهٔ تستِ محلی خروجی یکسان بود."),
]
print("کارِ این هفته:", len(CHANGES), "تغییر ·",
sum(len(t.splitlines()) for _, t, _, _ in CHANGES), "خطِ فایل")
کارِ این هفته: 8 تغییر · 264 خطِ فایل
حالا همین هشت تغییر را در تعدادِ commitِ متفاوت میچینیم. تابعِ build تنها یک ورودیِ مهم دارد: k، یعنی این کار به چند تکه تقسیم شود.
BULK_SUBJECT = ["نیمهٔ اولِ خطِ داده", "نیمهٔ دومِ خطِ داده", "بقیهٔ خطِ داده", "پایانِ خطِ داده"]
def build(name, k):
"""همان هشت تغییر را در k تا commit میچیند."""
root = WORK / name
root.mkdir(parents=True, exist_ok=True)
git("init", "-q", cwd=root)
n = len(CHANGES)
for step in range(k):
group = list(range(step * n // k, (step + 1) * n // k))
for i in group:
write(root / CHANGES[i][0], CHANGES[i][1])
git("add", "-A", cwd=root)
if len(group) > 1:
message = ["-m", BULK_SUBJECT[step]]
else:
message = ["-m", CHANGES[group[0]][2]]
if CHANGES[group[0]][3]:
message += ["-m", CHANGES[group[0]][3]]
git("commit", "-q", *message, cwd=root)
return root
def commits(root):
return list(reversed(git("log", "--format=%H", cwd=root).strip().split("\n")))
def added(root, rev):
"""خطهای افزودهٔ یک commit، به همان ترتیبی که بازبین میبیندشان."""
out = git("show", "--format=", "--unified=0", rev, cwd=root)
return [ln[1:] for ln in out.split("\n") if ln.startswith("+") and not ln.startswith("+++")]
small, bulk = build("repo-small", 8), build("repo-bulk", 2)
print("تاریخچهٔ ریز — اندازهٔ diffها:", [len(added(small, c)) for c in commits(small)])
print("تاریخچهٔ درشت — اندازهٔ diffها:", [len(added(bulk, c)) for c in commits(bulk)])
print("درختِ نهاییِ هر دو یکی است؟",
git("rev-parse", "HEAD^{tree}", cwd=small).strip()
== git("rev-parse", "HEAD^{tree}", cwd=bulk).strip())
تاریخچهٔ ریز — اندازهٔ diffها: [5, 13, 35, 50, 65, 80, 9, 3]
تاریخچهٔ درشت — اندازهٔ diffها: [103, 157]
درختِ نهاییِ هر دو یکی است؟ True
خطِ سوم مهمترین خطِ این بخش است. HEAD^{tree} اثرِ انگشتِ محتوای کلِ پروژه است، نه تاریخچهاش. یکی بودنشان یعنی دو مخزن دقیقاً یک چیز تحویل میدهند و هر تفاوتی که از این به بعد اندازه میگیریم، تفاوتِ تاریخچه است، نه تفاوتِ کار.
۲. بازبینی یک نیت نیست، یک بودجه است#
«بازبینیِ خوب» یک کیفیتِ اخلاقی نیست. بازبین وقت و توجهِ محدود دارد و آن را از بالای diff به پایین خرج میکند. بگذار این را صریح مدل کنیم: هر بازبین حداکثر چهل خط از هر commit را با دقت میخواند.
عددِ چهل یک فرض است و تنها فرضِ این مدل. بقیهٔ چیزها از diffهای واقعی میآید.
BUDGET = 40
print(f"{'commit':>8}{'بزرگترین diff':>16}{'خطِ خواندهشده':>16}{'سهمِ خواندهشده':>18}")
for k in (1, 2, 4, 8):
root = build(f"repo-{k}", k)
sizes = [len(added(root, sha)) for sha in commits(root)]
seen = sum(min(s, BUDGET) for s in sizes)
print(f"{k:>8}{max(sizes):>16}{seen:>16}{seen / sum(sizes):>18.1%}")
commit بزرگترین diff خطِ خواندهشده سهمِ خواندهشده
1 259 40 15.4%
2 157 80 30.8%
4 145 110 42.3%
8 80 185 71.2%
همان ۲۵۹ خط کار، و بین ۱۵٫۴٪ تا ۷۱٫۲٪ از آن جلوی چشمِ کسی میآید.
دلیلش ساده و بیرحم است: بودجه به هر commit تعلق میگیرد، نه به کلِ کار. هشت commit یعنی هشت بار چهل خط. یک commit یعنی یک بار چهل خط. کوچک کردنِ تغییر، تنها اهرمی است که بدونِ اضافه کردنِ حتی یک دقیقه به وقتِ بازبین، چیزی را که دیده میشود چند برابر میکند.
✅ چک کن: به ستونِ «بزرگترین diff» نگاه کن، نه به میانگین. با
k=4میانگین پایین آمده ولی هنوز یک commitِ ۱۴۵ خطی هست و همان یکی، سهمِ خواندهشده را زمین میزند. در بازبینی، بزرگترین تغییرِ توست که تعیین میکند چقدر دیده میشود، نه متوسطشان.
۳. شش خطا، دویست قرعه#
جدولِ بالا میگوید چقدر خوانده میشود. سؤالِ واقعی این است: چقدر پیدا میشود؟
پس شش خطا برمیداریم و روی خطهای افزودهٔ واقعی بهطورِ تصادفی پخششان میکنیم — چون از پیش نمیدانی خطایت کجای diff میافتد. این کار را دویست بار تکرار میکنیم، دقیقاً به همان دلیلی که ترمِ ۴ فصلِ ۶ بیست گروهِ قناری را امتحان کرد: یک اجرا از یک سامانهٔ تصادفی، یک نتیجه نیست، یک قرعه است.
def found(root, defects=6, draws=200):
"""۶ خطا را تصادفی روی خطهای افزودهٔ واقعی میگذاریم و میشماریم چندتا خوانده میشود."""
sizes = [len(added(root, sha)) for sha in commits(root)]
dice, hits = random.Random(5), 0
for _ in range(draws):
for spot in dice.sample(range(sum(sizes)), defects):
for size in sizes:
if spot < size:
hits += spot < BUDGET
break
spot -= size
return hits / draws
print(f"{'commit':>8}{'میانگینِ خطِ هر diff':>22}{'خطای پیداشده از ۶':>22}")
for k in (1, 2, 4, 8):
root = WORK / f"repo-{k}"
sizes = [len(added(root, sha)) for sha in commits(root)]
print(f"{k:>8}{sum(sizes) / len(sizes):>22.1f}{found(root):>22.2f}")
commit میانگینِ خطِ هر diff خطای پیداشده از ۶
1 259.0 0.96
2 130.0 1.72
4 65.0 2.40
8 32.5 4.17
از شش خطا، کمتر از یکی در برابرِ بیش از چهارتا.
و به یک چیز دقت کن که راحت از قلم میافتد: تراکمِ خطا در هر دو تاریخچه دقیقاً یکی است. شش خطا در ۲۵۹ خط. خطاها رقیقتر نشدهاند؛ چیزی که عوض شده، مقدارِ چیزی است که خوانده میشود.
📏 اندازه بگیر: با چه چیزی مقایسه شد؟ با خودش — همان هشت تغییر، همان درختِ نهایی، همان بودجهٔ خواندن. تنها متغیر تعدادِ commit است. روی کدام داده؟ روی diffهای واقعیِ گیت، نه روی اندازههای فرضی. با چند
seed؟ دویست قرعه برای جای خطاها. و صادقانه بگویم چه چیزی مدل است و چه چیزی اندازهگیری: «چهل خط بودجه» و «خطا تصادفی میافتد» دو فرضاند؛ اندازهٔ diffها، تعدادشان و یکی بودنِ درختِ نهایی اندازهگیریِ واقعیاند. بخشِ بعد یک عدد میدهد که هیچ فرضی ندارد.
۴. خطا رد شد و به تولید رسید#
آخرین تغییر یک بازنویسیِ کوچکِ «برای سرعت» بود: replaceِ زنجیرهای جایش را به str.translate داد. و یکی از دو جانشینی در راه گم شد. حالا ك عربی دیگر تبدیل نمیشود.
و چون همین دو تابع مقصرِ این فصلاند، دقیقاً بگوییم چه میکنند: str.maketrans({"ي": "ی"}) یک جدولِ جانشینی میسازد — یک dict که هر نویسه را به نویسهٔ جانشینش نگاشت میکند — و text.translate(TABLE) آن جدول را در یک گذر روی رشته اعمال میکند. همین «یک گذر» دلیلِ سرعتش است: زنجیرهٔ replace بهازای هر جانشینی یک رشتهٔ تازه میسازد، translate فقط یکی. و همینجاست که باگ مینشیند: جدول هر چه را که در آن dict نباشد بیصدا دستنخورده رد میکند — نه خطایی، نه هشداری. یک کلیدِ جاافتاده در جدول، یک نویسهٔ ترجمهنشده در تولید است.
این را یک تستِ سهخطی میگیرد. تست را بیرون از مخزن نگه میداریم تا در هر نقطه از تاریخچه بشود اجرایش کرد.
write(WORK / "check.py", '''import sys
sys.path.insert(0, sys.argv[1] + "/pipeline")
import clean
assert clean.normalise("كارت من") == "کارت من", clean.normalise("كارت من")
''')
code, out = run_py(str(WORK / "check.py"), str(small))
print("آزمون روی نسخهٔ نهایی — کدِ خروج:", code, "·", out.splitlines()[-1])
def bisect(root):
"""اولین commitِ خراب را با نصفکردنِ پیاپیِ تاریخچه پیدا میکند."""
history = commits(root)
git("bisect", "start", cwd=root)
git("bisect", "bad", history[-1], cwd=root)
git("bisect", "good", history[0], cwd=root)
steps = 0
while True:
steps += 1
failed, _ = run_py(str(WORK / "check.py"), str(root))
report = git("bisect", "bad" if failed else "good", cwd=root)
if "is the first bad commit" in report:
sha = report.split()[0]
break
git("bisect", "reset", cwd=root)
return steps, sha
for label, root in (("ریز", small), ("درشت", bulk)):
steps, sha = bisect(root)
print(f"تاریخچهٔ {label}: {steps} بار آزمایش · مقصر"
f" «{git('show', '-s', '--format=%s', sha, cwd=root).strip()}» ·"
f" {len(added(root, sha))} خطِ افزوده برای خواندن")
آزمون روی نسخهٔ نهایی — کدِ خروج: 1 · AssertionError: كارت من
تاریخچهٔ ریز: 3 بار آزمایش · مقصر «پاکسازی با translate، برای سرعت» · 3 خطِ افزوده برای خواندن
تاریخچهٔ درشت: 1 بار آزمایش · مقصر «نیمهٔ دومِ خطِ داده» · 157 خطِ افزوده برای خواندن
سه خط در برابرِ ۱۵۷ خط. این عدد هیچ فرضی ندارد — نه بودجهٔ خواندنی در کار است و نه توزیعِ تصادفی. git bisect تاریخچه را نصفنصف میکند تا اولین commitی را پیدا کند که تست در آن میشکند، و آنچه تحویلت میدهد یک diff است. اندازهٔ آن diff را تو تعیین کردهای، همان روزی که تصمیم گرفتی این کار در چند commit ثبت شود.
و به شمارشِ آزمایشها هم نگاه کن: تاریخچهٔ ریز سه بار آزمایش لازم داشت و درشت یک بار. بله، جستوجو در تاریخچهٔ بلندتر گامهای بیشتری دارد — ولی هر گام خودکار است و ثانیهای طول میکشد، در حالی که آن ۱۵۷ خط را باید یک آدم بخواند.
حالا کلِ علتِ خرابی را در یک صفحه ببین:
show = git("show", "--format=%s%n%n%b", commits(small)[-1], cwd=small).strip()
print("\n".join(ln for ln in show.split("\n") if not ln.startswith("index ")))
پاکسازی با translate، برای سرعت
زنجیرهٔ replace برای هر تیکت دو رشتهٔ تازه میسازد و translate یکی. روی نمونهٔ تستِ محلی خروجی یکسان بود.
diff --git a/pipeline/clean.py b/pipeline/clean.py
--- a/pipeline/clean.py
+++ b/pipeline/clean.py
@@ -1,5 +1,7 @@
"""clean.py — پاکسازیِ متنِ تیکت، پیش از هر کارِ دیگر."""
+TABLE = str.maketrans({"ي": "ی"})
+
def normalise(text):
- return " ".join(text.replace("ي", "ی").replace("ك", "ک").split())
+ return " ".join(text.translate(TABLE).split())
پیام، دلیل، و کلِ تغییر — روی یک صفحه. و جملهٔ «روی نمونهٔ تستِ محلی خروجی یکسان بود» دقیقاً همان سوراخ را نشان میدهد: نمونهٔ محلی ك عربی نداشت. این جمله را نویسندهاش برای دفاع از خودش ننوشته بود؛ نوشته بود چون فکر میکرد مفید است — و شش ماه بعد مفیدترین خطِ کلِ مخزن شد.
۵. تاریخچه بهعنوان مستند#
مخزن فقط انبارِ فایل نیست؛ قابلِ پرسش است. git log -S میگوید کدام commit یک رشتهٔ مشخص را وارد کرد یا از آن برداشت.
for label, root in (("ریز", small), ("درشت", bulk)):
print(f"«TK-00034» با کدام commit وارد شد؟ (تاریخچهٔ {label}) →",
"«" + git("log", "-S", "TK-00034", "--format=%s", cwd=root).strip() + "»")
print("پیامِ commitِ مقصر در تاریخچهٔ درشت:",
repr(git("log", "-1", "--format=%b", commits(bulk)[-1], cwd=bulk).strip()))
«TK-00034» با کدام commit وارد شد؟ (تاریخچهٔ ریز) → «نمونههای طلاییِ دستهٔ ارسال»
«TK-00034» با کدام commit وارد شد؟ (تاریخچهٔ درشت) → «نیمهٔ اولِ خطِ داده»
پیامِ commitِ مقصر در تاریخچهٔ درشت: ''
«نمونههای طلاییِ دستهٔ ارسال» یک جواب است. «نیمهٔ اولِ خطِ داده» فقط یک تاریخ است.
هر دو مخزن همان اطلاعات را دارند؛ فقط یکیشان قابلِ بازیابی است. و خطِ آخر بیرحمترین است: پیامِ commitِ مقصر در تاریخچهٔ درشت رشتهٔ خالی است. هیچکس ننوشت چرا. یک سال بعد، تنها جوابِ ممکن به «چرا اینطوری نوشته شده؟» این است: «نمیدانیم.»
قاعدهٔ پیامِ commit: عنوان میگوید چه چیزی عوض شد، بدنه میگوید چرا. «چه چیزی» را diff هم میگوید، پس اگر فقط یکی را مینویسی، «چرا» را بنویس.
۶. جریانِ کاری که از این عددها درمیآید#
| کاری که میکنی | عددی که پشتش است |
|---|---|
| هر کار روی شاخهٔ خودش، جدا از خطِ اصلی | برگشتپذیریِ کارِ ناتمام بدونِ دست زدن به بقیه |
| commitِ کوچک و مستقل | ۷۱٫۲٪ خطِ دیدهشده با هشت commit، در برابرِ ۱۵٫۴٪ با یک commit |
| هر commit یک تغییر، نه چند تا | diffِ ۳ خطی بهجای ۱۵۷ خطی هنگامِ bisect |
| بدنهٔ پیام، همیشه با «چرا» | جواب در برابرِ رشتهٔ خالی |
| بازبینی روی تغییر، نه روی کلِ فایل | بودجهٔ ثابت، پوششِ چند برابر |
و یک قاعدهٔ عملی که از ستونِ دومِ جدولِ بخشِ ۲ میآید: اگر تغییرت از بودجهٔ بازبینی بزرگتر است، بشکنش. حدِ دقیق مهم نیست؛ مهم این است که حدی داشته باشی و وقتی رد شد، کاری بکنی.
🔧 اگر کار نکرد: اگر سلولِ بخشِ ۴ با
RuntimeErrorو متنِfatal: Not a valid object nameبایستد، تابعِbisectرا دو بار پشتِ هم روی یک مخزن اجرا کردهای وgit bisect resetنیفتاده — سلول را از اول اجرا کن. اگرgitروی ماشینت نصب نباشد، سلولِ راهاندازی همان اول میگوید «git: نیست» و اولین فراخوانی باFileNotFoundErrorمیایستد. و اگرcommits(root)فهرستِ خالی داد،git commitبیصدا شکست خورده: تابعِgitعمداًuser.nameوuser.emailرا خودش میدهد، پس این فقط وقتی پیش میآید که چیزی برای commit کردن نبوده باشد.
🤖 از دستیارت بپرس: «
git bisect runچیست و چه فرقی با نصفکردنِ دستی دارد؟» بعد این را بپرس: «اگر تستِ من فقط در نیمی از اجراها میشکند،bisectچه جوابی میدهد و چرا آن جواب قابلِ اعتماد نیست؟» — جوابش تو را به همان «کمینهٔ نمونه» میرساند که ترمِ ۴ فصلِ ۶ سرِ دروازهٔ قناری ساخت.
واژههای تازهٔ این فصل#
| کلمه | تلفظ به حروف فارسی | یعنی چه |
|---|---|---|
| branch | برنچ | خطِ کاریِ جدا، تا کارِ ناتمام به خطِ اصلی نریزد |
| diff | دیف | تفاوتِ دو نسخه، همان چیزی که بازبین میخواند |
| code review | کد ریویو | خواندنِ تغییر توسطِ کسِ دیگر پیش از ورود به خطِ اصلی |
| commit message body | کامیت مسیج بادی | بخشِ زیرِ عنوانِ commit، جایی که «چرا» نوشته میشود |
| bisect | بایسکت | پیدا کردنِ اولین نسخهٔ خراب با نصفکردنِ پیاپیِ تاریخچه |
| blast radius | بلست ریدیس | مقدارِ کدی که یک تغییر میتواند خرابش کند |
تمرینها
اول خودت فکر کن یا امتحان کن — بعد اینجا را باز کن.
در فصل بعد#
بازبینیِ انسانی گران است و — همانطور که این فصل نشان داد — سوراخ دارد. فصلِ بعد آن چیزهایی را که ماشین بهتر از آدم میگیرد به ماشین میسپارد: یک دروازهٔ خودکار که پیش از هر ورود به خطِ اصلی اجرا میشود و تستِ سریع را از کند جدا میکند.
بعد عمداً بیلد را میشکنیم — یک بار روی نقضِ قراردادِ دادهٔ ترمِ ۲ و یک بار روی نشتیِ ترمِ ۱ — و میشماریم که هر ترتیبِ اجرا تا رسیدن به قرمز چند بار مدل آموزش میدهد.
به آخر این فصل رسیدی!
اگر ساختی و جواب داد، این دکمه مال توست.