Platform & Tooling · پلتفرم و ابزار پایهBeginner ~59 دقیقه مطالعه~50 min read

Git حرفه‌ای: از درون تا جریان کاری تیمیProfessional Git: Internals to Team Workflow

از مدل object و سه درخت تا rebase، reflog، bisect و جریان‌های کاری تیمی: هرچه یک مهندس ارشد باید از Git بداند تا تاریخچه را کنترل کند، تیم را هماهنگ نگه دارد و از هر فاجعه‌ای برگردد.From the object model and the three trees to rebase, reflog, bisect and team workflows: everything a senior engineer needs to control history, keep a team in sync, and recover from any disaster.


تقریباً هیچ آگهی شغلی مهندسی نرم‌افزار نیست که «تسلط بر Git» را نخواسته باشد. اما بین کسی که git add . && git commit -m "fix" && git push می‌زند و کسی که وقتی تاریخچهٔ یک تیم ده‌نفره وسط release به هم می‌ریزد می‌تواند نجاتش بدهد، یک دنیا فاصله است.

از پایین‌ترین لایه شروع می‌کنیم — اینکه Git روی دیسک واقعاً چه می‌نویسد — و بالا می‌آییم تا جریان کاری تیمی، سیاست merge، مرور کد و بازیابی از فاجعه. وقتی مدل ذهنی درست را داشته باشی دیگر دستورها را حفظ نمی‌کنی؛ از روی مدل استنتاجشان می‌کنی. مصاحبه‌گر ارشد دقیقاً همین را می‌سنجد.

نقشهٔ راه این فصل

۱–۳ درون Git: انبار محتوامحور و چهار object (blob / tree / commit / tag)؛ ref و HEAD؛ مدل «سه درخت».

۴–۸ کار روزمره: fast-forward در برابر three-way merge؛ rebase تعاملی و «قانون طلایی»؛ cherry-pick، stash و تفاوت خطرناک reset / revert / restore؛ حل conflict؛ reflog و bisect.

۹–۱۱ انتشار و پیکربندی: tag، SemVer و Conventional Commits؛ remote ها و force-push امن؛ ‏.gitignore، ‎.gitattributes، hook، submodule و LFS.

۱۲–۱۶ سطح تیم: چهار جریان کاری با دیاگرام؛ Pull Request و ruleset؛ release و hotfix؛ و فهرست فاجعه‌ها با دستور بازیابی.

نسخهٔ مبنا

همهٔ خروجی‌ها و flag های اینجا با Git 2.55 تأیید شده‌اند؛ هرجا قابلیتی نسخهٔ خاصی می‌خواهد صریح گفته‌ام.


۱. Git واقعاً چیست؟ یک پایگاه‌دادهٔ کلید-مقدار

انبار جعبه‌های مهروموم‌شده

تصور کن یک انبار داری. هر چیزی که می‌آوری داخل یک جعبه می‌رود، جعبه مهروموم می‌شود و رویش برچسبی می‌خورد که از خودِ محتویات ساخته شده — مثل اثر انگشت. اگر دو نفر دقیقاً یک چیز بیاورند برچسب هر دو یکی می‌شود و فقط یک جعبه نگه می‌داری؛ اگر ذره‌ای از محتویات عوض شود برچسب کاملاً عوض می‌شود.

Git همین انبار است. اسم فنی‌اش content-addressable object store است: انباری که آدرس هر چیز، خودِ محتوایش است.

جارگون را از صفر بسازیم: object کوچک‌ترین واحد ذخیره‌سازی Git است؛ hash / SHA تابعی است که هر ورودی با هر طولی را به رشته‌ای ثابت و ۴۰ کاراکتری تبدیل می‌کند (Git از SHA-1 استفاده می‌کند، حتی در ۲.۵۵) و این رشته هم آدرس است و هم مهر تمامیت. با چشم خودمان ببینیم:

git init demo && cd demo
printf 'hello world\n' > a.txt
git hash-object a.txt
# 3b18e512dba79e4c8300dd08aeb37f8e728b8dad

این عدد از کجا آمد؟ Git محتوا را خام هش نمی‌کند؛ اول یک header به شکل <نوع> <طول>\0 جلویش می‌چسباند:

printf 'blob 12\0hello world\n' | sha1sum
# 3b18e512dba79e4c8300dd08aeb37f8e728b8dad  -

دقیقاً همان عدد. هیچ جادویی در کار نیست — یک فرمول ساده و قابل بازتولید.

مدل ذهنی طلایی

Git مبتنی بر snapshot است، نه diff. هر commit یک عکس کامل از درخت پروژه است. چون آدرس هر object هش محتوای آن است، فایل تغییرنکرده هش‌اش عوض نمی‌شود و Git به همان object قبلی اشاره می‌کند — پس چیزی دوباره ذخیره نمی‌شود. اشتراک object، نه ذخیرهٔ diff، Git را ارزان می‌کند؛ diff چیزی است که در زمان نمایش محاسبه می‌شود.

چهار نوع object

نوع چه چیزی را نگه می‌دارد
blob محتوای خام یک فایل، بدون نام و بدون مجوز
tree یک پوشه: لیستی از <mode> <type> <sha>\t<name>
commit اشاره به یک tree ریشه + والد(ها) + author + committer + پیام
tag (annotated) اشاره به یک object + نام + tagger + پیام + امضای اختیاری

هر چهارتا را واقعاً بسازیم:

mkdir -p src && printf 'x\n' > src/b.txt
git add -A && git commit -m "init"

git cat-file -t HEAD     # commit
git cat-file -p HEAD
# tree 6bbaeff7e1c7c5273a642077161513885b3dd2fa
# author T <a@b.c> 1785444725 +0330
# committer T <a@b.c> 1785444725 +0330
#
# init

git ls-tree HEAD   # همان tree، یک سطح
# 100644 blob 3b18e512dba79e4c8300dd08aeb37f8e728b8dad	a.txt
# 040000 tree add4794d9c94872b96c1697c056fb82ae0d72880	src

git tag -a v1.0.0 -m "release 1.0.0"
git cat-file -p v1.0.0
# object b858574c4a9533bfdcadcb7f5fbda5ffb63705dd
# type commit
# tag v1.0.0

دیدی که commit خودش هیچ فایلی ندارد؛ فقط به یک tree اشاره می‌کند، و git ls-tree -r HEAD همان درخت را بازگشتی تا سطح blob باز می‌کند.

دیاگرام: گراف object در Git — هر commit به یک tree و به والدش اشاره می‌کند / The Git object graph: each commit points to a tree and to its parent.

flowchart LR
  TAG["tag v1.0.0"] --> C2["commit b858574"]
  C2 --> C1["commit 9f2a1c0 parent"]
  C2 --> T0["tree 6bbaeff root"]
  T0 --> B1["blob 3b18e51 a.txt"]
  T0 --> T1["tree add4794 src/"]
  T1 --> B2["blob 587be6b b.txt"]
SHA-1 شکسته است — اما Git امن است، و Git 3.0 دارد می‌آید

SHA-1 از نظر رمزنگاری در برابر collision شکسته شده (حملهٔ SHAttered، ۲۰۱۷). Git از ۲۰۱۸ از نسخهٔ سخت‌شدهٔ SHA-1DC استفاده می‌کند که تلاش برای collision را تشخیص می‌دهد و عملیات را رد می‌کند؛ پس در عمل ایمن است.

برنامهٔ رسمی این است که Git 3.0 پیش‌فرض را به SHA-256 ببرد و backend پیش‌فرض ref را از files به reftable تغییر دهد. تا ۲.۵۵ هنوز پیش‌فرض SHA-1 و files است — با git rev-parse --show-object-format چک کن، و اگر خواستی: git init --object-format=sha256 یا git init --ref-format=reftable. نکتهٔ عملی: مخزن SHA-256 با بسیاری از سرویس‌های میزبانی و ابزارهای CI امروز هنوز سازگار نیست؛ قبل از مهاجرت تست کن.

سؤال: چرا می‌گویند commit در Git یک snapshot است نه یک diff؟ اگر snapshot است چرا مخزن‌ها این‌قدر کوچک‌اند؟

پاسخ: هر commit به یک object از نوع tree اشاره می‌کند که وضعیت کامل درخت پروژه در آن لحظه است، نه فهرست تغییرات. پس در سطح مدل داده، commit یک snapshot است.

کوچک ماندن دو دلیل دارد. اول اشتراک object: فایل تغییرنکرده همان blob قبلی است و دوباره نوشته نمی‌شود؛ حتی زیردرخت‌های تغییرنکرده به همان tree قبلی اشاره می‌کنند. دوم packfile و delta compression: در git gc / git repack اشیاء در یک packfile فشرده و به‌صورت delta ذخیره می‌شوند — اما این بهینه‌سازی لایهٔ ذخیره‌سازی است، نه مدل داده.


۲. ref، HEAD و اینکه branch چقدر ارزان است

انبار پر از جعبه‌های بی‌نام است. برای اینکه آدم بتواند کار کند، روی بعضی جعبه‌ها یک برچسب چسبان می‌زنیم که می‌شود برداشت و روی جعبهٔ جدیدتر چسباند. در Git این برچسب‌ها ref نام دارند:

  • branch: refی زیر refs/heads/ که با هر commit خودکار جلو می‌رود.
  • tag: refی زیر refs/tags/ که حرکت نمی‌کند.
  • remote-tracking ref: refی زیر refs/remotes/<remote>/ — آخرین چیزی که تو از سرور دیده‌ای، نه چیزی که همین الان روی سرور است.
  • HEAD: اشاره‌گر «الان کجا ایستاده‌ای»؛ معمولاً یک symbolic ref به یک branch.
cat .git/HEAD                 # ref: refs/heads/main
git rev-parse HEAD            # b858574c4a9533bfdcadcb7f5fbda5ffb63705dd
git for-each-ref --format='%(refname) -> %(objectname:short)'

در backend کلاسیک files یک branch واقعاً یک فایل متنی با ۴۰ کاراکتر هش و یک newline است — ۴۱ بایت. به همین دلیل branch زدن در Git عملاً رایگان است، و همین رواج workflow های branch-محور را توضیح می‌دهد.

دیاگرام: ref، HEAD و object — چه چیزی به چه چیزی اشاره می‌کند / How refs and HEAD point into the object graph.

flowchart LR
  HEAD["HEAD symbolic ref"] --> MAIN["refs/heads/main"]
  MAIN --> C3["commit C3"]
  FEAT["refs/heads/feature"] --> C4["commit C4"]
  ORIGIN["refs/remotes/origin/main"] --> C2["commit C2"]
  TAGV["refs/tags/v1.0.0"] --> C2
  C4 --> C3 --> C2 --> C1["commit C1"]

اگر HEAD مستقیم به یک commit اشاره کند (نه به branch)، به آن detached HEAD می‌گویند: git switch --detach v1.0.0 (روش مدرن) یا git checkout v1.0.0 (روش قدیمی، همان اثر).

detached HEAD تنها جایی است که واقعاً کار از دست می‌رود

در حالت detached، commit زدن کار می‌کند اما هیچ branchی جلو نمی‌رود؛ به‌محض اینکه جای دیگری بروی آن commit‌ها بی‌ارجاع می‌شوند و بالاخره garbage collector پاکشان می‌کند. Git موقع خروج هشدار می‌دهد و هش را چاپ می‌کند و git reflog هم نجاتت می‌دهد (بخش ۸) — اما git gc --prune=now آن‌ها را برای همیشه می‌برد. راه امن قبل از خروج: git switch -c rescue/experiment


۳. سه درخت: مدلی که همه‌چیز را توضیح می‌دهد

سبد خرید در فروشگاه

سه فضا داری: قفسه‌های فروشگاه یعنی هرچه روی دیسک ویرایش می‌کنی (working treeسبد خرید یعنی چیزهایی که انتخاب کرده‌ای اما هنوز پول نداده‌ای (index یا staging area)؛ و رسید خرید قبلی یعنی آخرین چیزی که ثبت شده (HEAD).

git add یعنی «بریز در سبد»، git commit یعنی «برو پای صندوق». تمام دستورهای گیج‌کنندهٔ Git فقط دارند چیزی را بین این سه فضا جابه‌جا می‌کنند.

index یک فایل باینری در .git/index است که لیستی از مسیرها با blob-hash و metadata نگه می‌دارد. commit بعدی دقیقاً از index ساخته می‌شود، نه از working tree:

git ls-files -s
# 100644 3b18e512dba79e4c8300dd08aeb37f8e728b8dad 0	a.txt
# 100644 587be6b4c3f93f93c489c0111bba5596147a26cb 0	src/b.txt

آن 0 انتهایی stage number است. در حالت عادی همیشه 0 است؛ در حین conflict مقادیر 1 (base)، 2 (ours) و 3 (theirs) هم ظاهر می‌شوند — در بخش ۷ به آن برمی‌گردیم.

دیاگرام: سه درخت و دستوری که هر جابه‌جایی را انجام می‌دهد / The three trees and the command that moves data between them.

flowchart LR
  WT["Working tree"] -- "git add" --> IDX["Index / staging area"]
  IDX -- "git commit" --> HEADT["HEAD commit"]
  HEADT -- "git restore --staged" --> IDX
  IDX -- "git restore" --> WT
  HEADT -- "git reset --hard" --> WT
git status --short --branch      # خلاصهٔ فشرده برای استفادهٔ روزمره
git diff                         # working tree در برابر index  (آنچه stage نکرده‌ای)
git diff --staged                # index در برابر HEAD          (آنچه commit خواهد شد)
git diff HEAD                    # working tree در برابر HEAD   (مجموع هر دو)
git add -p                       # stage کردن تکه‌به‌تکه
git log --oneline --graph --decorate --all

عادتی که سطح یک مهندس را نشان می‌دهد git add -p است: به‌جای ریختن یک تغییر ۴۰۰ خطی درهم در یک commit، همان تغییر را به سه commit معنادار می‌شکنی — «refactor بدون تغییر رفتار»، «رفع باگ»، «تست» (کلیدها: y قبول، n رد، s شکستن hunk، e ویرایش دستی). و عادت دوم: قبل از هر commit یک بار git diff --staged بزن؛ تنها راه مطمئن شدن از اینکه کلید API وارد تاریخچه نمی‌شود همین است.


۴. انشعاب و ادغام: fast-forward در برابر three-way

git switch -c feature/checkout        # ساخت + رفتن روی آن  (Git 2.23+)
git branch --merged main              # branch های کاملاً داخل main → قابل حذف
git branch -d feature/checkout        # حذف امن: فقط اگر merge شده باشد

حالت اول — fast-forward: اگر branch مقصد از زمان انشعاب هیچ commit جدیدی نگرفته باشد، Git کاری برای ادغام ندارد و فقط برچسب main را روی نوک feature جلو می‌کشد. هیچ commit جدیدی ساخته نمی‌شود و گراف کاملاً خطی می‌ماند.

حالت دوم — three-way merge: اگر هر دو طرف commit جدید داشته باشند، Git سه نقطه را می‌گیرد: base (نزدیک‌ترین جد مشترک، از git merge-baseours (نوک branch فعلی) و theirs (نوک branch واردشونده)، و یک commit با دو والد می‌سازد. الگوریتم پیش‌فرض از Git 2.34 به بعد ort است (جایگزین recursive قدیمی) که سریع‌تر است و rename ها را بهتر تشخیص می‌دهد.

git merge --no-ff feature/checkout      # همیشه یک merge commit بساز
git merge --ff-only origin/main         # اگر fast-forward نبود شکست بخور
git merge --squash feature/checkout     # تغییرات را در index بریز، commit نزن

دیاگرام: three-way merge — commit ادغام دو والد دارد / Three-way merge creates a commit with two parents.

flowchart LR
  C1["C1 base"] --> C2["C2 main"]
  C1 --> C3["C3 feature"]
  C2 --> M["M merge commit"]
  C3 --> M
`--no-ff` یک تصمیم سیاستی است، نه سلیقه‌ای

با --no-ff همیشه یک merge commit ساخته می‌شود، حتی وقتی fast-forward ممکن بود. سودش این است که «مرز یک feature» در تاریخچه دیده می‌شود و git revert -m 1 <merge> کل feature را یک‌جا برمی‌گرداند؛ ضررش شلوغی گراف است. قاعدهٔ عملی تیم‌های خوب: --no-ff برای ورود feature به branch اصلی، --ff-only برای به‌روزرسانی کپی محلی خودت (git config --global pull.ff only).

نکتهٔ ظریف دربارهٔ --squash: این اصلاً merge نیست. commit حاصل فقط یک والد دارد و هیچ پیوند تباری با branch مبدأ ندارد، پس Git «نمی‌داند» آن branch ادغام شده — git branch --merged نشانش نمی‌دهد و merge دوبارهٔ همان branch همهٔ commit ها را دوباره اعمال می‌کند.


۵. rebase: بازنویسی تاریخ به‌شکل کنترل‌شده

جابه‌جا کردن پایهٔ نردبان

یک نردبان چندپله ساخته‌ای و به دیواری تکیه داده‌ای، و حالا می‌فهمی دیوار درست، دیوار کناری بود. یا بین دو دیوار پل می‌زنی (merge)، یا کل نردبان را برمی‌داری و پله‌به‌پله از نو روی دیوار درست می‌سازی (rebase).

نکتهٔ حیاتی: در rebase پله‌های قدیمی بازیافت نمی‌شوند؛ پله‌های جدیدی ساخته می‌شوند که شبیه قبلی‌اند اما هش متفاوتی دارند.

با git rebase main، ‏Git ‏commit های اختصاصی branch تو (main..feature) را برمی‌دارد و یکی‌یکی روی نوک main دوباره اعمال می‌کند. هر commit جدید هش جدید می‌گیرد، چون والدش عوض شده و هش commit شامل هش والد است.

دیاگرام: rebase — commitها بازنویسی می‌شوند و هش عوض می‌شود / Rebase replays commits onto a new base; hashes change.

flowchart LR
  C1["C1"] --> C2["C2 main"]
  C1 --> C3["C3 old"] --> C4["C4 old, dropped"]
  C2 --> C3p["C3' new hash"] --> C4p["C4' feature"]

rebase تعاملی: ابزار اصلی تمیزکاری تاریخ

با git rebase -i main (یا HEAD~5، یا --root) یک فایل «todo» باز می‌شود که هر خطش به شکل <دستور> <هش> <عنوان> است و تو با ویرایش همان خط‌ها تاریخچه را بازچینی می‌کنی:

دستور کوتاه چه می‌کند
pick p commit را همان‌طور که هست نگه دار
reword r محتوا را نگه دار، فقط پیام را ویرایش کن
edit e وسط این commit توقف کن تا محتوایش را عوض کنی
squash s با commit بالایی ادغام کن، پیام‌ها را با هم نشان بده
fixup f با commit بالایی ادغام کن، پیام این یکی را دور بریز
drop d این commit را کاملاً حذف کن
exec / break x / b دستور shell اجرا کن (شکست = توقف rebase) / همین‌جا بایست

وقتی وسط کار متوجه اشتباهی در یک commit قبلی شدی، به‌جای commit «fix typo» از --fixup استفاده کن:

git add src/Cart.java
git commit --fixup=a1b2c3d          # می‌سازد: "fixup! feat(cart): add coupon field"
git rebase -i --autosquash main     # خودکار سر جایش می‌نشیند

git config --global rebase.autoSquash true
git config --global rebase.autoStash true   # کار stage نشده را خودکار stash/unstash کند

سه شکل --fixup وجود دارد و اکثر مهندس‌ها فقط اولی را می‌شناسند: --fixup=<c> محتوای <c> را عوض می‌کند و پیامش را دست نمی‌زند؛ --fixup=amend:<c> هم محتوا و هم پیام را؛ و --fixup=reword:<c> فقط پیام را. Git 2.55 هم دستور آزمایشیgit history fixup|reword|split <commit> را اضافه کرد که همین کارها را بدون باز کردن ویرایشگر rebase انجام می‌دهد (با --dry-run و --update-refs) — چون EXPERIMENTAL است، در CI به آن تکیه نکن.

اگر branch ها را روی هم می‌سازی (stacked branches)، rebase کردن پایه معمولاً branch های بالایی را جا می‌گذاشت. git rebase --update-refs main (یا git config --global rebase.updateRefs true) این را حل می‌کند: طبق مستندات، هر branchی که به یکی از commit های در حال rebase اشاره می‌کند خودکار force-update می‌شود، به‌جز branch هایی که در worktree دیگری checkout شده‌اند.

قانون طلایی: هرگز تاریخچه‌ای را که دیگران رویش کار می‌کنند بازنویسی نکن

rebase (و commit --amend و reset --hard روی branch مشترک) commit های جدیدی با هش جدید می‌سازد. اگر آن branch دست دیگران هم باشد، پس از force-push تو تاریخچهٔ محلی آن‌ها واگرا می‌شود و git pull بعدی‌شان یک merge کثیف می‌سازد که نسخهٔ قدیمی و بازنویسی‌شده را هر دو وارد می‌کند.

قاعده: روی branch شخصی آزادانه rebase کن؛ روی main/develop/release هرگز. اگر مجبور شدی، در کانال تیم اعلام کن و به همه بگو (بعد از stash کردن کار محلی) git fetch origin && git reset --hard origin/<branch> بزنند.

rebase یا merge؟

معیار merge rebase
تاریخچه آنچه واقعاً اتفاق افتاد خطی و تمیز، اما بازنویسی‌شده
هش commit تغییر نمی‌کند تغییر می‌کند
امن روی branch عمومی بله خیر — force-push لازم می‌شود
conflict یک‌بار، در یک نقطه ممکن است در هر commit تکرار شود
git bisect / git blame نویز merge commit دارد تاریخچهٔ خطی، نتیجهٔ دقیق‌تر

راهبرد رایج و دفاع‌پذیر: rebase محلی برای تمیزکاری، merge برای یکپارچه‌سازی. branch خودت را با git pull --rebase روی main به‌روز نگه دار، ولی ورودش به main از طریق PR و یک merge کنترل‌شده باشد.

سؤال: تیم می‌خواهد تاریخچهٔ `main` خطی باشد. چه گزینه‌هایی داری و trade-off هرکدام چیست؟

پاسخ: سه گزینهٔ عملی.

۱. Squash merge: هر PR یک commit می‌شود. main بسیار خوانا می‌شود و bisect عالی کار می‌کند، اما جزئیات درون‌PR از بین می‌رود و branch باید بلافاصله حذف شود تا مشکل «merge دوباره» پیش نیاید.

۲. Rebase merge: تک‌تک commit های PR بازنویسی و روی main سوار می‌شوند. خطی می‌ماند و جزئیات هم حفظ می‌شود، اما فقط وقتی خوب است که نویسنده commit های تمیز و اتمی نوشته باشد؛ وگرنه ده commit «wip» وارد main می‌شود.

۳. Merge با --ff-only بعد از rebase محلی: نویسنده موظف است قبل از merge خودش rebase کند. روی مخزن پرترافیک این یک مسابقه ایجاد می‌کند (تا rebase تمام شود main جلو رفته) — راه‌حلش merge queue است.

پاسخ سطح ارشد هر سه را ذکر می‌کند و اضافه می‌کند که انتخاب باید با ابزار اجباری شود (سیاست merge روی مخزن قفل شود)، نه با توافق شفاهی.


۶. cherry-pick، stash و خانوادهٔ «برگرداندن»

cherry-pick یعنی برداشتن یک commit تکی و اعمالش جای دیگر. کاربرد واقعی‌اش hotfix است: اصلاحی که روی main نوشته‌ای باید روی release/2.4 هم برود، بدون بقیهٔ main.

git cherry-pick -x a1b2c3d              # یادداشت "cherry picked from ..."؛ بازه: a1b2c3d^..e4f5g6h
git cherry-pick --continue | --skip | --abort

cherry-pick یک commit جدید با هش جدید می‌سازد؛ اگر بعداً branch مبدأ را merge کنی Git دو نسخه از یک تغییر می‌بیند و وقتی خطوط اطراف عوض شده باشند conflict می‌گیری. همیشه -x بزن تا رد مبدأ بماند.

stash کشوی موقت است — در واقع یک commit بی‌ارجاع در refs/stash که push نمی‌شود:

git stash push -u -m "wip: coupon ui"   # -u یعنی untracked ها را هم ببر
git stash pop                           # اعمال کن و از لیست بردار
git stash branch fix/coupon stash@{2}   # branch نو از همان نقطه بساز و اعمال کن

سه stash سه‌ماهه روی لپ‌تاپ یعنی سه تکه کار که با یک دیسک سوخته از بین می‌رود. برای هر چیزی بیش از «۵ دقیقه باید سریع branch عوض کنم»، به‌جای stash یک branch موقت بساز و push کن.

reset در برابر revert در برابر restore

بیشترین اشتباه اینجا رخ می‌دهد، و همه‌چیز با مدل سه‌درخت روشن می‌شود:

دستور HEAD (branch) index working tree امن روی branch عمومی؟
git reset --soft <c> ✅ حرکت می‌کند بدون تغییر بدون تغییر ❌ تاریخچه را عوض می‌کند
git reset --mixed <c> (پیش‌فرض) ✅ حرکت می‌کند ✅ بازنشانی بدون تغییر
git reset --hard <c> ✅ حرکت می‌کند ✅ بازنشانی بازنویسی می‌شود ❌ و کار محلی نابود می‌شود
git restore <path> بدون تغییر بدون تغییر ✅ فایل از index بازمی‌گردد
git restore --staged <path> بدون تغییر ✅ فایل از HEAD بازنشانی بدون تغییر
git revert <c> commit جدید می‌سازد تنها راه امن
git reset --soft HEAD~1        # commit آخر را باز کن، تغییرات بماند
git restore --staged .env      # یک فایل را اشتباهی stage کردم
git restore src/Cart.java      # تغییرات محلی را دور بریز (غیرقابل بازگشت!)
`git checkout` سه کار متفاوت می‌کرد — به همین دلیل جایگزین شد

git checkout هم branch عوض می‌کرد، هم فایل بازمی‌گرداند، هم detached HEAD می‌ساخت. Git 2.23 آن را به دو دستور شکست و از Git 2.51 این دو رسماً از حالت آزمایشی خارج شدند: git switch برای جابه‌جایی بین branch ها و git restore برای بازگرداندن فایل.

خطر واقعی git checkout -- <file> و git restore <file> یکی است: بدون هیچ تأییدی کار ذخیره‌نشده را نابود می‌کنند و برخلاف commit، reflog نجاتت نمی‌دهد. هر جا شک داری اول git stash push بزن.

سؤال: یک commit باگ‌دار روی `main` رفته و push شده. `reset` یا `revert`؟

پاسخ: قطعاً revert.

git reset --hard HEAD~1 نوک main را عقب می‌برد؛ چون main قبلاً push شده، برای انتشار باید force-push کنی و آن commit را از تاریخچهٔ عمومی حذف کنی. نتیجه: هرکس قبلاً pull کرده هنوز آن commit را دارد و اولین git pull بعدی‌اش برش می‌گرداند، و هر branch یا tag یا PR بازی که به آن اشاره می‌کند خراب می‌شود.

git revert <sha> یک commit جدید می‌سازد که diff معکوس را اعمال می‌کند. تاریخچه فقط جلو می‌رود، هیچ‌کس کار خاصی لازم ندارد، و رد ماجرا برای audit می‌ماند.

نکتهٔ تکمیلی که امتیاز می‌آورد: برگرداندن یک merge commit به -m نیاز دارد چون Git نمی‌داند کدام والد «خط اصلی» است — git revert -m 1 <merge-sha> (عدد ۱ یعنی والد اول، معمولاً main). و اگر بعداً بخواهی همان feature را دوباره وارد کنی باید اول revert را revert کنی، وگرنه Git تغییرات را «قبلاً دیده‌شده» حساب می‌کند.


۷. conflict: چه هست و چطور درست حلش کنیم

conflict یعنی Git نتوانسته تصمیم بگیرد: در یک three-way merge هر دو طرف همان ناحیه از همان فایل را نسبت به base تغییر داده‌اند. اگر فقط یک طرف تغییر داده باشد، Git بی‌سروصدا همان را برمی‌دارد.

<<<<<<< HEAD
int total = price * qty;
=======
int total = price * qty - discount;
>>>>>>> feature/coupon

تنظیمی که conflict را چند برابر قابل‌فهم‌تر می‌کند و اکثر مهندس‌ها نمی‌دانند وجود دارد: git config --global merge.conflictStyle zdiff3. با zdiff3 بخش سومی هم نشان داده می‌شود — متن اصلی (base) بین ||||||| و =======؛ بدون آن فقط دو نتیجه را می‌بینی و نمی‌دانی هرکدام از چه چیزی شروع کرده‌اند.

چرخهٔ کار ساده است: git status فایل‌های both modified را نشان می‌دهد، هرکدام را ویرایش و git add می‌کنی، و در پایان git merge --continue — یا در هر لحظه git merge --abort تا همه‌چیز به قبل برگردد.

git diff --name-only --diff-filter=U        # فقط فایل‌های در conflict
git checkout --ours|--theirs path/file      # یک طرف را کامل بردار
git show :1:path/file                       # base   (‏:2: = ours، ‏:3: = theirs)
git mergetool                               # ابزار سه‌پنجره‌ای پیکربندی‌شده
git merge --abort                           # همه‌چیز به قبل از merge

آن :1: و :2: و :3: دقیقاً همان stage number هایی هستند که در بخش ۳ دیدیم — یعنی index در حین conflict سه نسخه از فایل را نگه می‌دارد.

اگر branch طولانی‌مدتی داری که هر rebase همان conflict را می‌دهد، git config --global rerere.enabled true را روشن کن. rerere یعنی reuse recorded resolution: Git شکل هر conflict و راه‌حل انتخابی تو را ثبت می‌کند و دفعهٔ بعد خودکار اعمالش می‌کند (git rerere forget <path> راه‌حل غلط را پاک می‌کند).

در rebase، معنی ours و theirs برعکس می‌شود

در git merge، «ours» یعنی branchی که رویش ایستاده‌ای. اما در git rebase، Git commit های تو را روی branch مقصد بازپخش می‌کند، پس در هر مرحله «ours» = branch مقصد (مثلاً main) و «theirs» = commit خودت. اگر بی‌فکر --ours بزنی، کارِ خودت را دور ریخته‌ای. راه امن: به‌جای حدس زدن، محتوا را نگاه کن؛ git status در حین rebase صریح می‌گوید کدام commit در حال اعمال است.

سؤال: بعد از حل conflict چطور مطمئن می‌شوی چیزی را خراب نکرده‌ای؟

پاسخ: حل conflict یک عمل «ویرایش متن» نیست، یک عمل مهندسی است. چک‌لیست من:

۱. هیچ marker باقی نمانده باشد: git grep -n '^<<<<<<<\|^>>>>>>>\|^|||||||' روی کل درخت. ۲. کد را واقعاً بخوان، نه اینکه فقط marker را پاک کنی — خیلی وقت‌ها هر دو طرف باید ترکیب شوند، نه اینکه یکی برنده شود. این کلاسیک‌ترین باگ پس از merge است. ۳. تست‌ها را کامل اجرا کن، نه فقط فایل تغییرکرده. conflict معنایی وجود دارد: هر دو طرف بدون conflict متنی merge می‌شوند ولی رفتار می‌شکند — مثلاً یکی امضای متد را عوض کرده و دیگری یک فراخوانی جدید اضافه کرده. ۴. git diff <merge-base> HEAD -- <file> را نگاه کن تا نتیجهٔ نهایی نسبت به نقطهٔ انشعاب را ببینی؛ و اگر شک داری git merge --abort و از نو، این بار با zdiff3.

اشاره به conflict معنایی همان چیزی است که سطح ارشد را نشان می‌دهد؛ اکثر داوطلب‌ها فقط تا مرحلهٔ ۱ می‌روند.


۸. reflog: آخرین سنگر — و bisect: یافتن رگرسیون

ref ها فقط وضعیت فعلی را نشان می‌دهند، اما Git یک دوربین مداربسته هم دارد: هر بار نوک یک ref جابه‌جا می‌شود یک فریم ضبط می‌کند. اسمش reflog است و تا وقتی فریم‌ها منقضی نشده‌اند تقریباً هیچ چیزی گم نمی‌شود.

git reflog                       # b858574 HEAD@{0}: commit (initial): init
git reflog show main             # حرکت یک branch خاص
git switch -c rescue HEAD@{5}    # نجات به شکل یک branch جدید
reflog محلی است، push نمی‌شود، و منقضی می‌شود

ورودی‌های reflog برای commit های قابل‌دسترس بعد از ۹۰ روز و برای غیرقابل‌دسترس‌ها بعد از ۳۰ روز منقضی می‌شوند (gc.reflogExpire و gc.reflogExpireUnreachable) — پنجرهٔ نجات محدود است، نه بی‌نهایت.

مهم‌تر: reflog فقط روی ماشین خودت است و با یک clone تازه صفر می‌شود. اگر همکارت force-push کرد و کارت را پاک کرد، reflog او حاوی commit های قبلی است نه reflog تو. و git gc --prune=now یا git reflog expire --expire=now --all این پنجره را فوراً می‌بندند.

اگر حتی reflog هم چیزی ندارد: git fsck --lost-found --unreachable و سپس git show <dangling-sha>.

bisect: پیدا کردن commit مقصر با جست‌وجوی دودویی

تستی که یک ماه پیش سبز بود حالا قرمز است و ۴۰۰ commit در این بازه هست. Git جست‌وجوی دودویی می‌کند — حدود ۹ قدم به‌جای ۴۰۰. حالت دستی: git bisect start، سپس git bisect bad و git bisect good v2.3.0، بعد در هر قدم good یا bad بگو، و در پایان git bisect reset. اما شکل حرفه‌ای‌اش خودکار است:

git bisect start HEAD v2.3.0    # یا: git rev-list -1 --before="3 weeks ago" main
git bisect run ./bisect-run.sh
git bisect log > bisect.log     # قابل بازتولید برای تیکت
#!/usr/bin/env bash
# bisect-run.sh
./gradlew --quiet compileJava || exit 125     # قابل تست نیست → skip
./gradlew --quiet test --tests '*CartTotalTest*'
کد خروجی اسکریپت معنی برای bisect
0 این commit good است
1124، 126، 127 این commit bad است
125 این commit قابل تست نیست → مثل git bisect skip
≥ 128 bisect را متوقف کن (خطای مرگبار)

آن 125 طلاست: وقتی build در بعضی commit ها کامپایل نمی‌شود، bisect به‌جای نتیجه‌گیری غلط آن commit را رد می‌کند. اگر تاریخچه پر از merge commit است، --first-parent فقط خط اصلی را دنبال می‌کند و مقصر را تا سطح «کدام PR» محدود می‌کند؛ و نام‌های good/bad هم با --term-new/--term-old قابل تغییرند.

سخت‌ترین قدم، نوشتن یک تست بازتولیدکنندهٔ قطعی است؛ bisect بدون سیگنال قابل‌اعتماد بی‌فایده است. و توجه کن که bisect دلیلی است که commit های اتمی و همیشه سبز ارزش دارند — اگر نیمی از commit های تیمی build نمی‌شود، bisect بی‌فایده می‌شود، و همین استدلالی برای سیاست squash-merge است.


۹. tag و نسخه‌گذاری معنایی

git tag -a v1.4.2 -m "release 1.4.2"   # annotated: یک object واقعی (بدون -a: lightweight)
git tag -s v1.4.2 -m "release 1.4.2"   # annotated + امضا؛ تأیید با -v
git push origin v1.4.2                 # tag ها خودکار push نمی‌شوند!
git tag -d v1.4.2 && git push origin :refs/tags/v1.4.2   # حذف محلی و روی سرور
git describe --tags --dirty            # v1.4.2-7-gb858574

برای release همیشه annotated tag بزن: git describe پیش‌فرض فقط annotated ها را می‌بیند، پس با tag های lightweight اسکریپت‌های نسخه‌گذاری build نسخهٔ غلط می‌سازند. و یک tag منتشرشده را هرگز جابه‌جا نکن — کسانی که قبلاً fetch کرده‌اند نسخهٔ قدیمی را نگه می‌دارند؛ اگر اشتباه کردی شمارهٔ جدید منتشر کن.

SemVer 2.0.0 قالب MAJOR.MINOR.PATCH دارد: MAJOR برای تغییر ناسازگار در API عمومی، MINOR برای قابلیت جدیدِ سازگار با عقب، PATCH برای رفع باگ. پیش‌انتشار مثل 1.5.0-rc.1 اولویت کمتری از نسخهٔ نهایی دارد و متادیتای build مثل 1.5.0+20260731 در مقایسه نادیده گرفته می‌شود.

اتصال طبیعی‌اش Conventional Commits 1.0.0 است — قراردادی برای پیام commit که ابزارها می‌توانند بخوانند: <type>[optional scope][!]: <description>، سپس بدنه و فوتر اختیاری.

feat(cart): apply coupon at checkout
fix(pricing): round tax to 2 decimals

feat(api)!: drop v1 endpoints

BREAKING CHANGE: /api/v1/* removed, migrate to /api/v2/*

نگاشت به SemVer: fix → PATCH، feat → MINOR، و ! یا فوتر BREAKING CHANGE: → MAJOR. همین است که به ابزارهای release خودکار اجازه می‌دهد نسخه و changelog را بدون دخالت انسان بسازند.

پیام commit را برای «آدمِ شش ماه بعد» بنویس: خط اول ≤ ۷۲ کاراکتر، وجه امری («add» نه «added»)، بدون نقطه؛ یک خط خالی؛ بعد بدنه‌ای که به چرا جواب می‌دهد نه چهچه را از diff می‌شود خواند، چرا فقط در سر توست. برای متادیتا هم trailer هست: git commit --trailer "Refs: PROJ-1421".


۱۰. remote ها: fetch، pull و push بدون فاجعه

remote فقط نامی مستعار برای آدرس یک مخزن دیگر است؛ origin یک قرارداد است، نه چیز ویژه‌ای. refspec می‌گوید کدام ref آن‌طرف به کدام ref این‌طرف نگاشت شود:

git remote show origin                     # وضعیت کامل، شامل branch های stale
git config --get remote.origin.fetch
# +refs/heads/*:refs/remotes/origin/*

آن refspec را بخوان: «همهٔ branch های آن‌طرف را بیاور و زیر refs/remotes/origin/ بگذار». علامت + یعنی «اجازه داری غیر-fast-forward هم به‌روزرسانی کنی» — به همین دلیل وقتی همکارت force-push می‌کند، origin/main تو بدون شکایت جابه‌جا می‌شود.

دیاگرام: fetch فقط دانلود می‌کند؛ merge یا rebase چیزی است که کار تو را عوض می‌کند / Fetch only downloads; merge or rebase is what changes your work.

sequenceDiagram
  participant W as Working tree
  participant L as Local branch main
  participant R as Remote-tracking origin/main
  participant S as Server
  W->>L: git commit
  L->>S: git push
  S-->>R: git fetch updates origin/main
  R->>L: git merge origin/main
  R->>L: or git rebase origin/main
  Note over R,L: git pull = fetch + merge (or rebase)
git fetch --prune origin               # دانلود + پاک کردن remote-tracking های حذف‌شده
git log --oneline HEAD..origin/main    # چه آمده که ندارم؟ (برعکسش: نفرستاده‌هایم)
git pull --rebase                      # = fetch + rebase

پیکربندی‌ای که تقریباً هر تیم حرفه‌ای می‌گذارد:

git config --global pull.rebase true          # pull به‌جای merge، rebase کند
git config --global fetch.prune true
git config --global push.autoSetupRemote true # اولین push خودکار upstream بسازد (Git 2.37+)
git config --global merge.conflictStyle zdiff3
git config --global diff.algorithm histogram  # diff خواناتر برای کد
`git pull` پیش‌فرض، مولد merge commit های بی‌معناست

git pull بدون تنظیم، وقتی branch محلی و remote واگرا شده باشند یک merge commit با پیام «Merge branch 'main' of ...» می‌سازد. ده تا از این‌ها در یک هفته، git log --graph را به تور ماهیگیری تبدیل می‌کند.

از Git 2.27 اگر pull.rebase تنظیم نشده باشد و تاریخچه واگرا شود، Git یک هشدار چاپ می‌کند و از تو می‌خواهد صریحاً انتخاب کنی. جواب درست برای اکثر تیم‌ها pull.rebase true است، یا اگر می‌خواهی هیچ‌وقت غافلگیر نشوی pull.ff only.

git push --force                                   # ❌ هرگز روی branch مشترک
git push --force-with-lease                        # ✅ فقط اگر remote همان جایی است که فکر می‌کنی
git push --force-with-lease --force-if-includes    # ✅✅ امن‌ترین حالت
git config --global alias.pushf 'push --force-with-lease --force-if-includes'
سؤال: تفاوت `--force` و `--force-with-lease` چیست و چرا `--force-if-includes` اضافه شد؟

پاسخ: --force بی‌قید و شرط ref سمت سرور را بازنویسی می‌کند؛ اگر همکارت همان لحظه چیزی push کرده باشد، commit او از branch حذف می‌شود (هرچند object تا زمان gc روی سرور می‌ماند).

--force-with-lease یک بررسی خوش‌بینانهٔ همزمانی اضافه می‌کند: Git مقدار مورد انتظار ref سمت سرور را (به‌طور پیش‌فرض همان refs/remotes/origin/<branch> محلی تو) می‌فرستد و سرور فقط در صورت تطابق قبول می‌کند — یعنی compare-and-swap.

مشکل باقی‌مانده این است که «مقدار مورد انتظار» ممکن است بدون آگاهی تو به‌روز شده باشد؛ مثلاً IDE هر دقیقه fetch می‌کند. آن‌وقت lease با کار جدید همکارت مطابقت دارد و push تو قبول می‌شود، در حالی که تو هرگز آن کار را ندیده‌ای.

--force-if-includes این حفره را می‌بندد. طبق مستندات رسمی این flag «بررسی می‌کند که نوک remote-tracking ref از یکی از ورودی‌های reflog برنچ محلی قابل‌دسترس باشد» — یعنی کار سرور واقعاً در کار محلی تو ادغام شده، نه فقط دانلود شده. روی branch محافظت‌شده هم force-push باید در تنظیمات پلتفرم کاملاً بسته باشد.


۱۱. ‏.gitignore، ‎.gitattributes، hook ها و فایل‌های بزرگ

target/                 # ساخته‌شده‌ها
.env                    # محیط و اسرار
.idea/                  # ابزار توسعه‌دهنده — بهتر است در ~/.config/git/ignore باشد
!config/default.env     # استثنا

ترتیب اولویت از ضعیف به قوی: $HOME/.config/git/ignore.gitignore پوشه‌های والد → .gitignore همان پوشه → .git/info/exclude. الگوی بعدی همیشه بر قبلی می‌چربد، و git check-ignore -v <path> می‌گوید کدام قاعده در کدام فایل مقصر بوده.

‏.gitignore روی فایل‌هایی که **قبلاً track شده‌اند** هیچ اثری ندارد

پرتکرارترین سردرگمی Git همین است. .gitignore فقط تصمیم می‌گیرد چه چیزی untracked بماند؛ فایلی که یک بار commit شده تا ابد ردیابی می‌شود. راهش git rm --cached .env است (از index بردار، روی دیسک نگه دار) و بعد افزودن به .gitignore.

هشدار جدی‌تر: اگر آن .env حاوی کلید بوده، کلید هنوز در تاریخچه است. تنها راه درست و به همین ترتیب: (۱) فوراً کلید را در سرویس مربوطه باطل و جایگزین کن، (۲) بعد تاریخچه را با git filter-repo بازنویسی کن. ترتیب مهم است — بازنویسی تاریخچه زمان می‌برد و در این فاصله کلید لو رفته است.

.gitattributes ابزار کم‌شناخته اما پرقدرتی است:

* text=auto                        # در مخزن LF، در working tree بومی سیستم
*.sh text eol=lf                   # اسکریپت شل همیشه LF، حتی روی ویندوز
*.png binary                       # هرگز diff یا تبدیل خط نکن
CHANGELOG.md merge=union           # هر دو طرف را نگه دار: بدون conflict
.github/ export-ignore             # از آرشیو release حذف شود

merge=union ترفندی واقعی برای فایل‌های «فقط افزودنی» مثل CHANGELOG.md است که همیشه conflict می‌دهند چون همه به انتهایشان خط اضافه می‌کنند — اما فقط جایی به‌کارش ببر که ترتیب و تکرار بی‌ضرر است؛ روی کد نتیجه‌اش کدی است که کامپایل نمی‌شود در حالی که merge «موفق» بوده.

hook ها

hook اسکریپتی اجرایی است که Git در نقاط مشخصی از چرخهٔ عمر اجرا می‌کند. محل پیش‌فرضشان .git/hooks/ است که push نمی‌شود؛ برای اشتراک در تیم git config core.hooksPath .githooks بگذار، یا از چارچوب pre-commit استفاده کن که نسخه و نصب را از روی .pre-commit-config.yaml خودکار مدیریت می‌کند.

hook کِی اجرا می‌شود کاربرد رایج
pre-commit قبل از ساخت commit lint، format، اسکن secret
commit-msg بعد از نوشتن پیام اعتبارسنجی Conventional Commits
pre-push قبل از ارسال تست سریع، جلوگیری از push به main
pre-receive / update روی سرور، قبل از پذیرش اجبار سیاست — قابل دور زدن نیست
hook سمت کلاینت هرگز یک کنترل امنیتی نیست

هر توسعه‌دهنده‌ای می‌تواند با git commit --no-verify (کوتاه: -n) تمام hook های pre-commit و commit-msg را دور بزند، یا git push --no-verify برای pre-push. hook کلاینتی یک حلقهٔ بازخورد سریع است، نه یک دروازه.

هر قاعدهٔ مهم باید در CI و در قوانین سمت سرور (ruleset / pre-receive) هم اجرا شود: همان lint در hook کلاینت برای بازخورد دوثانیه‌ای، و همان lint در CI به‌عنوان status check اجباری.

submodule، subtree و monorepo

submodule یعنی یک مخزن Git داخل مخزن دیگر که والد فقط اشاره‌گری به یک commit مشخص از آن را ذخیره می‌کند؛ subtree محتوای مخزن دیگر را داخل تاریخچهٔ تو می‌آورد پس clone ساده می‌ماند؛ و monorepo همه‌چیز را در یک مخزن جمع می‌کند.

git submodule add https://example.org/team/lib.git libs/lib
git clone --recurse-submodules <url>
git submodule update --init --recursive       # اگر یادت رفت
git subtree add --prefix=libs/lib <url> main --squash
submodule یک سطح حملهٔ واقعی است — CVE-2025-48384

در جولای ۲۰۲۵ آسیب‌پذیری CVE-2025-48384 (CVSS 8.1) منتشر شد: Git هنگام خواندن مقدار پیکربندی \r انتهایی را حذف می‌کرد اما هنگام نوشتن آن را نقل‌قول نمی‌کرد. یک .gitmodules دستکاری‌شده می‌توانست باعث شود submodule در مسیر اشتباه checkout شود؛ در ترکیب با یک symlink و یک hook از نوع post-checkout، نتیجه اجرای کد با یک git clone --recursive ساده بود. این مورد به فهرست KEV آژانس CISA (بهره‌برداری فعال) اضافه شد و در نسخه‌های 2.43.7، 2.44.4، 2.45.4، 2.46.4، 2.47.3، 2.48.2، 2.49.1 و 2.50.1 اصلاح شد. درس عملی: Git را به‌روز نگه دار و هرگز مخزن ناشناس را با --recursive clone نکن.

انتخاب بین این سه یک trade-off است: submodule مرز نسخه را صریح و مخزن را سبک نگه می‌دارد ولی همه باید دستور اضافه یاد بگیرند و فراموشی --recurse-submodules رایج است؛ subtree ‏clone را عادی نگه می‌دارد ولی تاریخچه را بزرگ می‌کند و ارسال تغییر به بالادست را سخت‌تر؛ و monorepo ‏refactor اتمی روی چند سرویس می‌دهد ولی به ابزار نیاز دارد:

git clone --filter=blob:none <url>            # partial clone: blob ها بعداً می‌آیند
git clone --depth=1 <url>                     # shallow clone: مناسب CI
git sparse-checkout init --cone && git sparse-checkout set services/payments
scalar clone <url>                            # پیکربندی خودکار برای مخزن بزرگ

فایل‌های بزرگ و Git LFS

Git برای فایل متنی کوچک ساخته شده. یک ویدیوی ۲۰۰ مگابایتی که ده بار عوض شود مخزن را برای همیشه ۲ گیگابایت می‌کند، چون تاریخچه پاک نمی‌شود. راه‌حل استاندارد Git LFS است (نسخهٔ پایدار فعلی: ۳.۷.x): با git lfs install && git lfs track "*.psd" قاعده در .gitattributes نوشته می‌شود (*.psd filter=lfs diff=lfs merge=lfs -text) و از آن پس به‌جای محتوا یک pointer file کوچک در Git ذخیره می‌شود؛ برای تاریخچهٔ موجود هم git lfs migrate import --include="*.psd" --everything.

سه تلهٔ واقعی‌اش: (۱) پلتفرم‌ها برای فضا و پهنای‌باند LFS سهمیه و هزینه دارند و CI ای که هر بار clone کامل می‌کند سهمیه را می‌سوزاند — در CI از GIT_LFS_SKIP_SMUDGE=1 استفاده کن؛ (۲) هرکس client LFS نصب نکرده باشد به‌جای فایل متن pointer می‌بیند؛ (۳) خروج از LFS نیاز به بازنویسی تاریخچه دارد. قبل از LFS اول بپرس: آیا این فایل اصلاً باید در کنترل نسخه باشد یا جایش یک artifact repository است؟


۱۲. چهار جریان کاری تیمی — و اینکه کِی کدام

جریان کاری (workflow) یعنی قرارداد تیم دربارهٔ اینکه چه branch هایی وجود دارند، کد از کجا به کجا می‌رود و release چطور ساخته می‌شود. چهار الگوی رایج را با هم مقایسه کنیم.

الف) Git Flow — دو branch دائمی (main و develop) و سه نوع موقت

دیاگرام: Git Flow — دو branch دائمی و سه نوع موقت / Git Flow with two permanent and three temporary branch types.

flowchart LR
  F1["feature/x"] --> D["develop"]
  F2["feature/y"] --> D
  D --> R["release/1.4"]
  R --> M["main tagged v1.4.0"]
  R --> D
  H["hotfix/1.4.1"] --> M
  H --> D

مناسب وقتی نسخه‌های شماره‌داری که مشتری نصب می‌کند داری (کتابخانه، اپ موبایل، on-premise). نامناسب برای SaaS با استقرار روزانه؛ آنجا develop فقط merge ها را دوبرابر می‌کند.

ب) GitHub Flow — یک main همیشه قابل استقرار

دیاگرام: GitHub Flow — یک main همیشه سبز / GitHub Flow keeps a single always-deployable main.

flowchart LR
  M0["main"] --> B["feature/short-lived"]
  B --> PR["Pull Request + CI + review"]
  PR --> M1["main merged"]
  M1 --> DEP["deploy to production"]

مناسب برای سرویسی که مرتب deploy می‌شود و rollback سریع دارد؛ نامناسب وقتی باید دو نسخه را هم‌زمان پشتیبانی کنی.

ج) GitLab Flow — مثل GitHub Flow به‌علاوهٔ branch های محیط

دیاگرام: GitLab Flow — کد فقط رو به جلو در محیط‌ها جریان دارد / GitLab Flow promotes code forward through environment branches.

flowchart LR
  FT["feature branch"] --> MAIN["main"]
  MAIN --> STG["pre-production"]
  STG --> PRD["production"]
  HF["hotfix"] --> PRD
  HF --> MAIN

مناسب وقتی استقرار زمان‌بندی‌شده است، یا الزام انطباق داری و «چه چیزی دقیقاً در production است» باید یک ref قابل‌ممیزی باشد.

د) Trunk-Based — انشعاب در کد، نه در Git

همه با branch های کمتر از یک روز روی یک trunk کار می‌کنند و کار ناتمام پشت feature flag پنهان می‌شود.

دیاگرام: Trunk-based — انشعاب پشت flag اتفاق می‌افتد / Trunk-based moves the branch into the code, behind a flag.

flowchart LR
  DEV["short-lived branch under 1 day"] --> TRUNK["main trunk"]
  TRUNK --> CI["CI on every commit"]
  CI --> ART["build artifact once"]
  ART --> ENVS["deploy to all environments"]
  FLAG["feature flag off by default"] --> ENVS

مناسب برای تیم بالغ با تست خودکار قوی و سامانهٔ feature flag. نامناسب وقتی پوشش تست ضعیف است — trunk-based بدون تست یعنی production شکسته.

معیار Git Flow GitHub Flow GitLab Flow Trunk-Based
branch دائمی main + develop main main + محیط‌ها main
پشتیبانی چند نسخه عالی ضعیف خوب با release branch
فراوانی release زمان‌بندی‌شده پیوسته دروازه‌دار چند بار در روز
نیاز به feature flag کم متوسط متوسط الزامی
مناسب برای محصول نسخه‌دار SaaS SaaS با انطباق تیم بالغ CD
انتخاب workflow یک تصمیم مهندسی است، نه سلیقه

سه سؤال بپرس و جواب خودش بیرون می‌آید: چند نسخه را هم‌زمان پشتیبانی می‌کنی؟ بیش از یکی → به release/* نیاز داری. چند بار در روز deploy می‌کنی؟ بیش از یک بار → develop را حذف کن. آیا می‌توانی کد ناتمام را با flag خاموش نگه داری؟ اگر نه، trunk-based جواب نمی‌دهد.

خطای رایج: Git Flow را برای یک SaaS انتخاب می‌کنند «چون استاندارد است»، و شش ماه بعد develop و main سه هفته از هم عقب می‌افتند.

سؤال: تیم ۱۲ نفرهٔ ما روی یک SaaS کار می‌کند، هفته‌ای یک بار deploy می‌کنیم و branch ها دو هفته باز می‌مانند. مشکل کجاست؟

پاسخ: مشکل اصلی branch های طولانی‌عمر است، نه انتخاب ابزار. هرچه branch بیشتر باز بماند فاصله‌اش از main بیشتر می‌شود؛ conflict نمایی رشد می‌کند و مرور کد به یک PR غول‌پیکر تبدیل می‌شود که کسی جدی نمی‌خواندش.

راه‌حل به‌ترتیب اولویت: (۱) کار را کوچک کن — هر PR زیر حدود ۴۰۰ خط تغییر و کمتر از دو روز عمر؛ feature بزرگ را به چند PR مستقلاً قابل merge بشکن. (۲) feature flag اضافه کن تا کد نیمه‌کاره بتواند در main بنشیند بدون اینکه فعال باشد — همین است که «branch کوتاه» را ممکن می‌کند. (۳) develop را حذف کن و به GitHub Flow برو. (۴) CI را روی هر PR اجباری کن و اگر مخزن پرترافیک است merge queue فعال کن. (۵) deploy را از هفتگی به پیوسته ببر؛ استقرارهای کوچک‌تر ریسک کمتری دارند و خودشان انگیزهٔ branch کوتاه را می‌سازند.


۱۳. Pull Request و مرور کد در سطح حرفه‌ای

کیفیت مرور کد با اندازهٔ PR به‌شدت افت می‌کند: یک PR دوهزار خطی معمولاً با «LGTM» تأیید می‌شود، یک PR ۱۵۰ خطی نظر واقعی می‌گیرد. سه راهکار: refactor را از تغییر رفتار جدا کن؛ تغییرات مکانیکی مثل rename و format را جداگانه بفرست؛ و از stacked PR استفاده کن که با git rebase --update-refs نگهداری‌اش ساده است.

محور مرور چه چیزی را چک کن
درستی حالت‌های مرزی، null، خطا، همزمانی، تراکنش
دامنه آیا PR فقط همان کاری را می‌کند که عنوانش می‌گوید؟
تست تست شکست را قبل از اصلاح می‌گرفت؟ تست منفی هم هست؟
امنیت ورودی اعتبارسنجی شده؟ secret در کد نیست؟ مجوز چک شده؟
کارایی و مشاهده‌پذیری کوئری N+1؟ log و metric کافی برای عیب‌یابی در production؟

زبان مرور را از «تو» به «کد» ببر: به‌جای «چرا اینجا null چک نکردی؟» بنویس «اگر order اینجا null باشد چه اتفاقی می‌افتد؟». و شدت را صریح کن؛ بسیاری از تیم‌ها با پیشوند کار می‌کنند: blocking: باید قبل از merge حل شود، suggestion: اختیاری، nit: سلیقه‌ای، question: فقط می‌خواهم بفهمم — آن‌وقت نویسنده می‌داند دقیقاً چه چیزی جلوی merge را گرفته.

دروازه‌های سمت سرور

قواعدی که واقعاً اجرا می‌شوند آن‌هایی هستند که پلتفرم اعمال می‌کند، نه آن‌هایی که در ویکی نوشته شده. branch محافظت‌شده / ruleset: ممنوعیت push مستقیم، اجبار PR، حداقل تعداد تأیید، اجبار سبز بودن status check، ممنوعیت force-push و حذف branch، اجبار امضای commit، و باطل شدن تأییدها با هر push جدید. امروز ruleset جایگزین مدرن branch protection است: چند ruleset هم‌زمان اعمال می‌شوند، در سطح سازمان تعریف می‌گردند و فهرست bypass با ردّ ممیزی دارند.

CODEOWNERS مالک هر مسیر را تعیین و تأیید او را اجباری می‌کند:

# .github/CODEOWNERS  (یا CODEOWNERS در ریشه یا docs/)
*                       @org/platform-team
/services/payments/**   @org/payments-team
/infra/**               @org/sre @org/security

الگوها مثل .gitignore هستند و آخرین الگوی منطبق برنده است — پس قاعدهٔ عمومی را بالا و قواعد خاص را پایین بنویس.

merge queue: پلتفرم PR ها را در صف می‌چیند، هرکدام را روی نتیجهٔ قبلی تست می‌کند و فقط در صورت سبز بودن merge می‌کند — تنها راه مقیاس‌پذیر برای جلوگیری از «هر دو PR جدا سبز بودند ولی با هم main را شکستند».

سیاست merge تاریخچهٔ main چه زمانی خطر
Merge commit گراف کامل، مرز feature پیدا وقتی می‌خواهی کل feature را یک‌جا revert کنی گراف شلوغ
Squash merge خطی، یک commit به‌ازای هر PR پیش‌فرض خوب برای اکثر تیم‌ها؛ bisect عالی جزئیات درون PR گم می‌شود؛ branch باید حذف شود
Rebase merge خطی، همهٔ commit ها حفظ وقتی تیم commit اتمی می‌نویسد تاریخچه بازنویسی می‌شود؛ commit های wip وارد main می‌شوند
سیاست squash با branch های بلندمدت سمّی است

اگر main را با squash merge می‌بندی، حتماً حذف خودکار branch پس از merge را روشن کن. اگر کسی روی branch merge‌شده کار را ادامه بدهد، Git هیچ ارتباط تباری بین commit های او و commit فشرده‌شدهٔ main نمی‌بیند و نتیجه انبوهی conflict تکراری در PR بعدی است. به همین دلیل ترکیب «Git Flow + squash merge» تقریباً همیشه اشتباه است.

سؤال: تیم می‌خواهد تضمین کند هیچ‌وقت کد بدون مرور به production نرود. چه لایه‌هایی می‌گذاری؟

پاسخ: دفاع لایه‌ای، چون هر لایهٔ منفرد قابل دور زدن است:

۱. سمت سرور و غیرقابل دور زدن: ruleset روی main — ممنوعیت push مستقیم، اجبار PR با حداقل یک تأیید (برای مسیرهای حساس تأیید CODEOWNERS)، اجبار status check های CI، ممنوعیت force-push و حذف، باطل شدن تأییدها با push جدید. ۲. هویت: فیلدهای author و committer در یک commit متن ساده و کاملاً جعل‌پذیرند؛ تنها راه واقعی، امضای رمزنگاری است:

git config --global gpg.format ssh
git config --global user.signingkey ~/.ssh/id_ed25519.pub
git config --global commit.gpgsign true
git config --global tag.gpgsign true
git log --show-signature -1

۳. CI به‌عنوان دروازه: build، تست، lint، اسکن secret و وابستگی — همه به‌عنوان status check اجباری؛ و merge queue اگر مخزن پرترافیک است. ۴. hook کلاینتی فقط برای بازخورد سریع، چون --no-verify دورش می‌زند. ۵. ممیزی: لاگ bypass ها را مرور کن؛ اگر تیم مدام bypass می‌کند یعنی قواعد با واقعیت کار جور نیست.

نکتهٔ تکمیلی: امضا فقط می‌گوید «این object را این کلید امضا کرده»؛ اگر merge یا squash در سرور انجام شود، commit نهایی معمولاً با کلید خود پلتفرم امضا می‌شود نه با کلید نویسنده.


۱۴. release branch و hotfix

دیاگرام: چرخهٔ hotfix — اصلاح روی خط release و بازگرداندن آن به trunk / Hotfix flow: fix on the release line, then merge back to trunk.

sequenceDiagram
  participant P as production v2.4.0
  participant R as release/2.4
  participant M as main
  P->>R: incident reported
  R->>R: create hotfix/2.4.1 and fix
  R->>P: tag v2.4.1 and deploy
  R->>M: merge back or cherry-pick -x
  Note over R,M: never let the fix live only on the release branch
git switch -c hotfix/2.4.1 v2.4.0        # از tag دقیقی که در production است، نه از main
git commit -m "fix(order): prevent NPE when coupon is null"
git tag -a v2.4.1 -m "hotfix 2.4.1"
git push origin hotfix/2.4.1 v2.4.1
git switch main && git cherry-pick -x <hotfix-sha>
شایع‌ترین اشتباه hotfix: فراموش کردن بازگشت به trunk

اصلاح روی release/2.4 می‌رود، production درست می‌شود، همه خوشحال‌اند — و سه هفته بعد release 2.5 همان باگ را دوباره به production می‌برد چون اصلاح هرگز به main نرسید. به این «رگرسیون بازگشتی» می‌گویند.

دو محافظ: در تعریف «انجام‌شده» برای هر hotfix، merge یا cherry-pick به trunk را الزامی کن؛ و در CI هشدار بده اگر commitی روی release/* هست که معادلش روی main نیست: git cherry -v main release/2.4 | grep '^+'.


۱۵. فاجعه‌ها و دستور دقیق بازیابی

فاجعه دستور بازیابی
پیام commit آخر اشتباه است git commit --amend (فقط اگر push نشده)
اشتباهی git reset --hard زدم git reflog سپس git reset --hard HEAD@{1}
branch حذف‌شده merge نشده بود git reflog یا git fsck --lost-found، سپس git switch -c name <sha>
commit بد روی main رفته و push شده git revert <sha>؛ برای merge: git revert -m 1 <sha>
rebase یا merge از دستم در رفت git rebase --abort / git merge --abort، یا git reset --hard ORIG_HEAD
همکارم force-push کرد و کارم رفت روی ماشین خودت git refloggit branch rescue <sha>
کلید API را commit کردم اول کلید را باطل کن، سپس git filter-repo --invert-paths --path secrets.env و force-push هماهنگ‌شده

ORIG_HEAD نقطهٔ قبل از آخرین merge/rebase/reset است. دو ابزار تحقیقاتی دیگر هم بشناس: git log -L :calculateTotal:src/Cart.java تکامل یک تابع را در طول زمان نشان می‌دهد، و git range-diff main old-feature new-feature دو نسخه از یک مجموعه commit را (مثلاً قبل و بعد از rebase) مقایسه می‌کند.

و یک هدیه به آیندهٔ تیم: بعد از یک format سراسری، git blame برای همیشه خراب می‌شود مگر اینکه هش آن commit را در .git-blame-ignore-revs ثبت کنی و git config --global blame.ignoreRevsFile .git-blame-ignore-revs بگذاری.

سؤال: کسی روی `main` مشترک `git push --force` زده و دو روز کار تیم گم شده. چه می‌کنی؟

پاسخ: به‌ترتیب:

۱. جلوی خون‌ریزی را بگیر: اعلام کن هیچ‌کس pull یا push نکند؛ هر git pull جدید ممکن است وضعیت محلی سالم را خراب کند. ۲. یک نسخهٔ سالم پیدا کن: از کسی که اخیراً fetch کرده بخواه git reflog show origin/main را بفرستد؛ object ها تا زمان gc هنوز روی سرور هستند، پس پلتفرم هم معمولاً هش قبلی را از event log می‌دهد. ۳. بازگردانی: با هش سالم git branch rescue <sha> بساز و با هماهنگی git push --force-with-lease origin rescue:main بزن. ۴. همگام‌سازی تیم: به همه بگو بعد از stash کردن کار محلی، git fetch origin && git reset --hard origin/main بزنند. ۵. پیشگیری، مهم‌ترین بخش پاسخ: force-push روی main باید در تنظیمات سرور کاملاً ممنوع باشد؛ مشکل واقعی پیکربندی مخزن است نه آن توسعه‌دهنده.

پاسخ ضعیف فقط مرحلهٔ ۳ را می‌گوید؛ پاسخ ارشد با «چرا اصلاً ممکن بود؟» تمام می‌شود.

سؤال: مخزن بعد از چند سال کند شده؛ `git status` چند ثانیه طول می‌کشد. چه می‌کنی؟

پاسخ: اول اندازه‌گیری (git count-objects -vH و git rev-list --count --all)، بعد درمان به‌ترتیب اثر:

۱. git maintenance start تا کارهای پس‌زمینه‌ای مثل commit-graph، prefetch، loose-objects، pack-refs و incremental-repack زمان‌بندی شوند؛ commit-graph به‌تنهایی پیمایش تاریخچه را چند برابر سریع می‌کند. ۲. fsmonitor که با watcher سیستم‌عامل تغییرات را دنبال می‌کند و git status را روی درخت‌های بزرگ بسیار سریع می‌کند — روی ویندوز و macOS از قبل بود و Git 2.55 پشتیبانی لینوکس را با inotify اضافه کرد. ۳. برای مخزن‌های واقعاً بزرگ: git clone --filter=blob:none و git sparse-checkout --cone، یا مستقیماً scalar clone؛ و در CI به‌جای clone کامل، --depth=1.

اگر ریشهٔ مشکل فایل‌های بزرگ تاریخی است هیچ‌کدام کافی نیست و باید تاریخچه را با git filter-repo بازنویسی کرد — که هزینهٔ هماهنگی کل تیم را دارد.


۱۶. برگهٔ تقلب

نیاز دستور
وضعیت فشرده / گراف تاریخچه git status -sb / git log --oneline --graph --decorate --all
جست‌وجوی متن در تاریخچه / تکامل یک تابع git log -S "text" --all / git log -L :func:path/file
تمیزکاری تاریخ git rebase -i --autosquash <base>
به‌روزرسانی امن / push امن پس از بازنویسی git pull --rebase / git push --force-with-lease --force-if-includes
چه چیزی نفرستاده‌ام / نگرفته‌ام git log --oneline @{u}..HEAD / HEAD..@{u}
کشوی موقت / یافتن commit مقصر git stash push -u -m "msg" / git bisect run <script>
نگهداری خودکار مخزن git maintenance start

git worktree را کمتر کسی می‌شناسد و بیشتر از همه وقت صرفه‌جویی می‌کند: وقتی وسط یک feature هستی و hotfix فوری می‌رسد لازم نیست stash کنی — با git worktree add ../project-hotfix -b hotfix/2.4.1 v2.4.0 یک پوشهٔ کاری دوم از همان مخزن می‌سازی. object ها مشترک‌اند پس فضایی مصرف نمی‌شود و build cache هرکدام دست‌نخورده می‌ماند.

سؤال: مدل object در Git را در ۹۰ ثانیه توضیح بده.

پاسخ: Git یک پایگاه‌دادهٔ کلید-مقدار محتوامحور است. کلید، هش SHA-1 از <type> <size>\0<content> است و مقدار، خود object. چهار نوع object داریم: blob (محتوای فایل، بدون نام و مجوز)، tree (یک پوشه: لیستی از mode type sha name که به blob ها و tree های دیگر اشاره می‌کند)، commit (یک tree ریشه + صفر یا چند والد + author + committer + پیام) و tag annotated (اشاره‌ای نام‌دار و قابل امضا به یک object).

روی این گراف تغییرناپذیر، لایهٔ ref نشسته است: refs/heads/* برای branch، refs/tags/* برای tag، refs/remotes/* برای remote-tracking، و HEAD که معمولاً symbolic ref به یک branch است. index یک فایل باینری جداست که «commit بعدی» را می‌سازد.

از این مدل سه نتیجه مستقیماً بیرون می‌آید: branch ارزان است چون فقط یک فایل ۴۱ بایتی است؛ تاریخچه غیرقابل دستکاری است چون هر تغییر همهٔ هش‌های پایین‌دستی را عوض می‌کند؛ و بازنویسی تاریخ همیشه یعنی «ساخت object جدید»، نه ویرایش object قدیمی — که دقیقاً دلیل قانون طلایی rebase است.

جمع‌بندی

Git یک پایگاه‌دادهٔ محتوامحور از object های تغییرناپذیر است — blob برای محتوا، tree برای پوشه، commit برای snapshot، tag برای نام‌گذاری — با لایه‌ای نازک از ref رویش و HEAD که می‌گوید کجا ایستاده‌ای. هر commit یک عکس کامل است نه یک diff. مدل سه درخت (working tree / index / HEAD) هر دستوری را قابل استنتاج می‌کند.

merge تاریخ را حفظ می‌کند و rebase آن را بازنویسی — پس rebase فقط روی کاری که هنوز مال توست. revert تنها راه امن برگرداندن چیزی است که push شده، reflog تقریباً همه‌چیز را در پنجرهٔ ۳۰ تا ۹۰ روزه برمی‌گرداند و bisect run مقصر را در چند قدم پیدا می‌کند.

در سطح تیم، انتخاب workflow را با سه سؤال بگیر — چند نسخه پشتیبانی می‌کنی، چند بار deploy می‌کنی، آیا feature flag داری — و بدان که branch های کوتاه و PR های کوچک بیشتر از هر ابزاری کیفیت را بالا می‌برند. قواعد واقعی را سمت سرور اجبار کن، چون hook کلاینتی با --no-verify دور زده می‌شود. و در نهایت این چند عادت: --force-with-lease --force-if-includes به‌جای -f، annotated tag برای هر release، zdiff3 برای conflict، و git maintenance start روی هر مخزن بزرگ.

Almost no software engineering job posting skips "solid Git skills". But there is a world of difference between someone who types git add . && git commit -m "fix" && git push and someone who can rescue a team's history when it breaks mid-release.

We start at the lowest layer — what Git writes to disk — and climb up to team workflow, merge policy, code review and disaster recovery. Once you own the right mental model you stop memorising commands and start deriving them, which is exactly what a senior interviewer measures.

Roadmap for this chapter

1–3 Inside Git: the content-addressable store and its four objects (blob / tree / commit / tag); refs and HEAD; the "three trees" model.

4–8 Daily work: fast-forward versus three-way merge; interactive rebase and the "golden rule"; cherry-pick, stash and the dangerous differences between reset / revert / restore; conflicts; reflog and bisect.

9–11 Release and configuration: tags, SemVer and Conventional Commits; remotes and safe force-push; .gitignore, .gitattributes, hooks, submodules and LFS.

12–16 Team level: four workflows with diagrams; pull requests and rulesets; releases and hotfixes; and a disaster table with the exact recovery command.

Baseline version

Every output and flag here was verified against Git 2.55; where a feature needs a specific version I say so.


1. What Git really is: a key-value database

A warehouse of sealed boxes

Picture a warehouse. Everything you bring in goes into a sealed box with a label derived from its own contents — like a fingerprint. If two people bring the same thing both labels are identical and you keep one box; change a byte and the label changes completely.

Git is that warehouse. The technical name is content-addressable object store: a store where the address of a thing is its content.

Let us build the jargon from zero: an object is Git's smallest unit of storage; a hash / SHA turns any input of any length into a fixed 40-character hex string (Git uses SHA-1, still the default in 2.55), and that string is both the address and the integrity seal:

git init demo && cd demo
printf 'hello world\n' > a.txt
git hash-object a.txt
# 3b18e512dba79e4c8300dd08aeb37f8e728b8dad

Git does not hash the raw content; it first prepends a header of the form <type> <length>\0:

printf 'blob 12\0hello world\n' | sha1sum
# 3b18e512dba79e4c8300dd08aeb37f8e728b8dad  -

Exactly the same number: a simple, reproducible formula.

The golden mental model

Git is snapshot-based, not diff-based: every commit is a complete picture of the project tree. Because an object's address is the hash of its content, an unchanged file keeps its hash and Git points at the same old object — nothing is stored twice. Object sharing, not diff storage, makes Git cheap; a diff is computed at display time.

The four object types

Type What it holds
blob The raw content of a file — no name, no permissions
tree A directory: a list of <mode> <type> <sha>\t<name> entries
commit A pointer to a root tree + parent(s) + author + committer + message
tag (annotated) A pointer to an object + name + tagger + message + signature

Let us build all four:

mkdir -p src && printf 'x\n' > src/b.txt
git add -A && git commit -m "init"

git cat-file -t HEAD     # commit
git cat-file -p HEAD
# tree 6bbaeff7e1c7c5273a642077161513885b3dd2fa
# author T <a@b.c> 1785444725 +0330
# committer T <a@b.c> 1785444725 +0330
#
# init

git ls-tree HEAD   # the same tree, one level down
# 100644 blob 3b18e512dba79e4c8300dd08aeb37f8e728b8dad	a.txt
# 040000 tree add4794d9c94872b96c1697c056fb82ae0d72880	src

git tag -a v1.0.0 -m "release 1.0.0"
git cat-file -p v1.0.0
# object b858574c4a9533bfdcadcb7f5fbda5ffb63705dd
# type commit
# tag v1.0.0

Notice the commit contains no files; it only points at a tree. git ls-tree -r HEAD expands it down to the blobs.

Diagram: the Git object graph — each commit points to a tree and to its parent / نمودار گراف object در Git.

flowchart LR
  TAG["tag v1.0.0"] --> C2["commit b858574"]
  C2 --> C1["commit 9f2a1c0 parent"]
  C2 --> T0["tree 6bbaeff root"]
  T0 --> B1["blob 3b18e51 a.txt"]
  T0 --> T1["tree add4794 src/"]
  T1 --> B2["blob 587be6b b.txt"]
SHA-1 is broken — but Git is safe, and Git 3.0 is coming

SHA-1 is cryptographically broken against collisions (the SHAttered attack, 2017). Since 2018 Git has shipped the hardened SHA-1DC variant, which detects collision attempts and rejects the operation, so in practice it is safe.

The official plan is for Git 3.0 to switch the default hash to SHA-256 and the default ref backend from files to reftable. Through 2.55 the defaults are still SHA-1 and files — check with git rev-parse --show-object-format, and opt in with git init --object-format=sha256 or git init --ref-format=reftable. Caveat: SHA-256 repositories are still incompatible with many hosting services and CI tools, so test before migrating.

Question: why is a Git commit called a snapshot rather than a diff? And if it is a snapshot, why are repositories so small?

Answer: Every commit points at a tree object describing the complete state of the project tree at that moment, not a list of changes — so at the data-model level a commit is a snapshot.

Repositories stay small for two reasons. First, object sharing: an unchanged file is the same blob and is never written again; even unchanged subtrees reuse the same tree. Second, packfiles and delta compression: git gc / git repack store objects as deltas against similar objects inside a packfile — a storage-layer optimisation, not the data model.


2. refs, HEAD, and just how cheap a branch is

The warehouse is full of anonymous boxes with only fingerprints, so we stick a sticky label on some of them — a label you can peel off and move to a newer box. In Git those labels are refs:

  • branch: a ref under refs/heads/ that advances automatically with every commit.
  • tag: a ref under refs/tags/ that never moves.
  • remote-tracking ref: a ref under refs/remotes/<remote>/ — the last thing you saw on the server, not what is on the server right now.
  • HEAD: the "where am I standing" pointer; usually a symbolic ref to a branch.
cat .git/HEAD                 # ref: refs/heads/main
git rev-parse HEAD            # b858574c4a9533bfdcadcb7f5fbda5ffb63705dd
git for-each-ref --format='%(refname) -> %(objectname:short)'

In the classic files backend a branch is a text file holding a 40-character hash plus a newline — 41 bytes. That is why branching in Git is effectively free, which explains the rise of branch-centric workflows.

Diagram: how refs and HEAD point into the object graph / نمودار ارجاع ref و HEAD به گراف object.

flowchart LR
  HEAD["HEAD symbolic ref"] --> MAIN["refs/heads/main"]
  MAIN --> C3["commit C3"]
  FEAT["refs/heads/feature"] --> C4["commit C4"]
  ORIGIN["refs/remotes/origin/main"] --> C2["commit C2"]
  TAGV["refs/tags/v1.0.0"] --> C2
  C4 --> C3 --> C2 --> C1["commit C1"]

If HEAD points directly at a commit instead of a branch, that is a detached HEAD: git switch --detach v1.0.0 (modern) or git checkout v1.0.0 (old, same effect).

Detached HEAD is the one place where work genuinely disappears

While detached you can commit, but no branch moves forward; the moment you go elsewhere those commits become unreachable and the garbage collector eventually removes them. Git warns on the way out and prints the hash, and git reflog still saves you (section 8) — but git gc --prune=now takes them for good. The safe move before leaving: git switch -c rescue/experiment


3. The three trees: the model that explains everything

A shopping basket

You have three spaces: the shop shelves are everything you edit on disk (working tree); the basket holds what you picked but have not paid for (index, or staging area); and the previous receipt is the last thing recorded (HEAD).

git add means "put it in the basket", git commit means "go to the till". Every confusing Git command only moves something between these three spaces.

The index is a binary file at .git/index listing paths with blob hashes and metadata. The next commit is built exactly from the index, not from the working tree:

git ls-files -s
# 100644 3b18e512dba79e4c8300dd08aeb37f8e728b8dad 0	a.txt
# 100644 587be6b4c3f93f93c489c0111bba5596147a26cb 0	src/b.txt

That trailing 0 is the stage number. Normally it is always 0; during a conflict you also see 1 (base), 2 (ours) and 3 (theirs) — we come back to that in section 7.

Diagram: the three trees and the command that moves data between them / نمودار سه درخت و دستور جابه‌جایی بین آن‌ها.

flowchart LR
  WT["Working tree"] -- "git add" --> IDX["Index / staging area"]
  IDX -- "git commit" --> HEADT["HEAD commit"]
  HEADT -- "git restore --staged" --> IDX
  IDX -- "git restore" --> WT
  HEADT -- "git reset --hard" --> WT
git status --short --branch      # compact summary for daily use
git diff                         # working tree vs index  (what you have NOT staged)
git diff --staged                # index vs HEAD          (what WILL be committed)
git diff HEAD                    # working tree vs HEAD   (the sum of both)
git add -p                       # stage hunk by hunk
git log --oneline --graph --decorate --all

The habit that shows an engineer's level is git add -p: instead of dumping one tangled 400-line change into a single commit, you split it into three meaningful commits — "behaviour-preserving refactor", "bug fix", "test" (keys: y, n, s to split, e to edit). Second habit: run git diff --staged before every commit; it is the only reliable way to be sure an API key never enters history.


4. Branching and merging: fast-forward versus three-way

git switch -c feature/checkout        # create + switch  (Git 2.23+)
git branch --merged main              # fully contained in main → safe to delete
git branch -d feature/checkout        # safe delete: only if merged

Case one — fast-forward: if the target branch got no new commit since the fork point, Git has nothing to merge; it drags the main label forward to the feature tip. No commit is created and the graph stays linear.

Case two — three-way merge: if both sides have new commits, Git takes three points — base (nearest common ancestor, from git merge-base), ours (tip of the current branch) and theirs (tip of the incoming branch) — and builds a commit with two parents. Since Git 2.34 the default strategy is ort, which is faster than the old recursive and detects renames better.

git merge --no-ff feature/checkout      # always create a merge commit
git merge --ff-only origin/main         # fail if it would not be a fast-forward
git merge --squash feature/checkout     # dump the changes into the index, do not commit

Diagram: a three-way merge creates a commit with two parents / نمودار three-way merge با دو والد.

flowchart LR
  C1["C1 base"] --> C2["C2 main"]
  C1 --> C3["C3 feature"]
  C2 --> M["M merge commit"]
  C3 --> M
`--no-ff` is a policy decision, not a matter of taste

With --no-ff a merge commit is always created, even when a fast-forward was possible. The upside: the "boundary of a feature" stays visible and git revert -m 1 <merge> undoes the whole feature at once; the downside is a busier graph. The rule good teams use: --no-ff to merge a feature into the main branch, --ff-only to update your own local copy (git config --global pull.ff only).

A subtle point about --squash: it is not a merge. The resulting commit has only one parent and no ancestry link to the source branch, so Git does not know that branch was merged — git branch --merged will not list it, and merging it again replays every commit.


5. rebase: rewriting history in a controlled way

Moving the foot of a ladder

You built a multi-rung ladder against a wall and now realise the correct wall was next door. Either you bridge the two walls (merge), or you rebuild the ladder rung by rung against the right wall (rebase).

The crucial part: rebase does not recycle the old rungs; it creates new ones that look like the old ones but carry different hashes.

With git rebase main, Git takes the commits belonging only to your branch (main..feature) and reapplies them one at a time onto the tip of main. Each new commit gets a new hash, because its parent changed and a commit hash includes the parent hash.

Diagram: rebase replays commits onto a new base; hashes change / نمودار بازپخش commit ها روی پایهٔ جدید در rebase.

flowchart LR
  C1["C1"] --> C2["C2 main"]
  C1 --> C3["C3 old"] --> C4["C4 old, dropped"]
  C2 --> C3p["C3' new hash"] --> C4p["C4' feature"]

Interactive rebase: the main history-cleanup tool

git rebase -i main (or HEAD~5, or --root) opens a "todo" file whose lines look like <command> <hash> <subject>; you reshape history by editing them.

Command Short What it does
pick p keep the commit as it is
reword r keep the content, edit only the message
edit e stop in the middle of this commit so you can change its content
squash s merge into the commit above, combining both messages
fixup f merge into the commit above, discarding this message
drop d remove this commit entirely
exec / break x / b run a shell command (failure stops the rebase) / stop here

When you spot a mistake in an earlier commit mid-flight, use --fixup instead of a "fix typo" commit:

git add src/Cart.java
git commit --fixup=a1b2c3d          # produces: "fixup! feat(cart): add coupon field"
git rebase -i --autosquash main     # it slots into the right place automatically

git config --global rebase.autoSquash true
git config --global rebase.autoStash true   # auto stash/unstash uncommitted work

There are three forms of --fixup and most engineers know only the first: --fixup=<c> changes the content of <c> only; --fixup=amend:<c> changes content and message; --fixup=reword:<c> changes only the message. Git 2.55 also added the EXPERIMENTAL git history fixup|reword|split <commit>, doing the same jobs without the rebase editor (with --dry-run and --update-refs) — do not depend on it in CI.

If you stack branches, rebasing the base used to strand the upper ones. git rebase --update-refs main (or git config --global rebase.updateRefs true) fixes that: per the documentation, any branch pointing at a commit being rebased is force-updated automatically, except branches checked out in another worktree.

The golden rule: never rewrite history other people are working on

Rebase (and commit --amend, and reset --hard on a shared branch) creates new commits with new hashes. If others have that branch too, after your force-push their history diverges and their next git pull builds a messy merge containing both the old and the rewritten version.

The rule: rebase freely on your own branch; never on main, develop or a release line. If you must, announce it and tell everyone to run (after stashing local work) git fetch origin && git reset --hard origin/<branch>.

rebase or merge?

Criterion merge rebase
History what actually happened linear and clean, but rewritten
Commit hashes unchanged changed
Safe on a public branch yes no — a force-push is required
Conflicts once, in one place may repeat on every commit
git bisect / git blame noisy with merge commits linear history, sharper results

The common, defensible strategy: rebase locally to clean up, merge to integrate. Keep your branch current with git pull --rebase, but let it enter main through a pull request and a controlled merge.

Question: the team wants a linear history on `main`. What are the options and their trade-offs?

Answer: Three practical options.

  1. Squash merge: every PR becomes one commit. main is very readable and bisect works beautifully, but intra-PR detail is lost and the branch must be deleted immediately so the "merged again" problem never appears.

  2. Rebase merge: each PR commit is rewritten onto main. History stays linear and detailed, but only if the author wrote clean atomic commits; otherwise ten "wip" commits land on main.

  3. --ff-only merge after a local rebase: the author must rebase before merging. On a busy repository this creates a race — by the time you finish, main has moved — and the answer is a merge queue.

A senior answer names all three and adds that the choice must be enforced by tooling, not by verbal agreement.


6. cherry-pick, stash and the "undo" family

cherry-pick takes a single commit and applies it somewhere else. Its real use is the hotfix: a fix written on main must also reach release/2.4, without the rest of main.

git cherry-pick -x a1b2c3d              # records "cherry picked from ..."; range: a1b2c3d^..e4f5g6h
git cherry-pick --continue | --skip | --abort

cherry-pick creates a new commit with a new hash; if you later merge the source branch Git sees two copies of one change, and when the surrounding lines have moved you get a conflict. Always pass -x so the origin is recorded.

stash is a temporary drawer — really an unreferenced commit in refs/stash that is never pushed:

git stash push -u -m "wip: coupon ui"   # -u also takes untracked files
git stash pop                           # apply and remove from the list
git stash branch fix/coupon stash@{2}   # create a new branch at that point and apply

Three three-month-old stashes on a laptop are three pieces of work that vanish with one dead disk. Beyond "I need to switch branches for five minutes", push a temporary branch instead.

reset versus revert versus restore

Most mistakes happen here, and the three-tree model explains all of it:

Command HEAD (branch) index working tree Safe on a public branch?
git reset --soft <c> ✅ moves unchanged unchanged ❌ rewrites history
git reset --mixed <c> (default) ✅ moves ✅ reset unchanged
git reset --hard <c> ✅ moves ✅ reset overwritten ❌ and local work is destroyed
git restore <path> unchanged unchanged ✅ file restored from index
git restore --staged <path> unchanged ✅ file reset from HEAD unchanged
git revert <c> ✅ creates a new commit the only safe way
git reset --soft HEAD~1        # reopen the last commit, keep the changes
git restore --staged .env      # I staged a file by mistake
git restore src/Cart.java      # throw away local changes (unrecoverable!)
`git checkout` did three unrelated jobs — that is why it was replaced

git checkout switched branches, restored files, and created detached HEADs. Git 2.23 split it into two commands, and as of Git 2.51 both officially left experimental status: git switch for moving between branches and git restore for restoring files.

The real danger of git checkout -- <file> and git restore <file> is identical: they destroy unsaved work with no confirmation, and unlike a commit, reflog cannot save you. When in doubt, run git stash push first.

Question: a buggy commit landed on `main` and was pushed. `reset` or `revert`?

Answer: Definitely revert.

git reset --hard HEAD~1 moves the tip of main backwards; since main was already pushed, publishing that needs a force-push that deletes the commit from public history. Everyone who already pulled still has it, their next git pull brings it back, and any branch, tag or open PR pointing at it breaks.

git revert <sha> creates a new commit applying the inverse diff. History only moves forward, nobody has to do anything special, and the audit trail survives.

The bonus point: reverting a merge commit needs -m, because Git does not know which parent is the mainline — git revert -m 1 <merge-sha> (1 is the first parent, usually main). And if you later want that feature back you must first revert the revert, or Git treats the changes as already seen.


7. Conflicts: what they are and how to resolve them properly

A conflict means Git could not decide: in a three-way merge both sides changed the same region of the same file relative to the base. If only one side changed it, Git takes that side silently.

<<<<<<< HEAD
int total = price * qty;
=======
int total = price * qty - discount;
>>>>>>> feature/coupon

One setting makes conflicts far more understandable and most engineers do not know it: git config --global merge.conflictStyle zdiff3. With zdiff3 a third section appears — the original text (base) between ||||||| and =======; without it you see two outcomes and cannot tell what each started from.

The loop is simple: git status lists the both modified files, you edit each and git add it, then finish with git merge --continue — or git merge --abort to undo everything.

git diff --name-only --diff-filter=U        # only the conflicted files
git checkout --ours|--theirs path/file      # take one whole side
git show :1:path/file                       # base   (:2: = ours, :3: = theirs)
git mergetool                               # your configured three-pane tool
git merge --abort                           # back to before the merge

Those :1:, :2: and :3: are the stage numbers from section 3 — during a conflict the index keeps three versions of the file.

If a long-lived branch produces the same conflict on every rebase, set git config --global rerere.enabled true. rerere means reuse recorded resolution: Git records each conflict's shape and your resolution, then reapplies it automatically (git rerere forget <path> clears a bad one).

During a rebase, "ours" and "theirs" are reversed

In git merge, "ours" is the branch you are standing on. But in git rebase, Git replays your commits onto the target branch, so at each step "ours" = the target branch (e.g. main) and "theirs" = your own commit. Reach for --ours without thinking and you have thrown away your own work. The safe habit: read the content instead of guessing; git status during a rebase names the commit being applied.

Question: after resolving a conflict, how do you know you did not break something?

Answer: Resolving a conflict is an engineering act, not a text-editing act. My checklist:

  1. No markers left behind: git grep -n '^<<<<<<<\|^>>>>>>>\|^|||||||' across the whole tree.
  2. Read the code instead of just deleting markers — very often both sides must be combined, not one declared winner. This is the classic post-merge bug.
  3. Run the full test suite, not just the changed file. Semantic conflicts exist: both sides merge without a textual conflict but behaviour breaks — one side changed a method signature while the other added a call.
  4. Check git diff <merge-base> HEAD -- <file> against the fork point; if unsure, git merge --abort and start again with zdiff3.

Mentioning semantic conflicts is what marks a senior; most candidates stop at step 1.


8. reflog: the last line of defence — and bisect: finding a regression

Refs only show the current state, but Git also runs a security camera: every time a ref tip moves it records a frame. That is the reflog, and until those frames expire almost nothing is truly lost.

git reflog                       # b858574 HEAD@{0}: commit (initial): init
git reflog show main             # movements of one specific branch
git switch -c rescue HEAD@{5}    # rescue it as a new branch
reflog is local, is never pushed, and expires

Reflog entries expire after 90 days for reachable commits and 30 days for unreachable ones (gc.reflogExpire, gc.reflogExpireUnreachable) — the rescue window is finite.

More importantly the reflog exists only on your machine and is empty in a fresh clone. If a colleague force-pushed and wiped your work, it is their reflog that holds the earlier commits. And git gc --prune=now or git reflog expire --expire=now --all close that window immediately.

If even the reflog has nothing: git fsck --lost-found --unreachable, then git show <dangling-sha>.

bisect: finding the guilty commit by binary search

A test that was green a month ago is red now, with 400 commits in between. Git does a binary search — about 9 steps instead of 400. Manually: git bisect start, then git bisect bad and git bisect good v2.3.0, answer good or bad at each step, and finish with git bisect reset. The professional form is automated:

git bisect start HEAD v2.3.0    # or: git rev-list -1 --before="3 weeks ago" main
git bisect run ./bisect-run.sh
git bisect log > bisect.log     # reproducible, attach it to the ticket
#!/usr/bin/env bash
# bisect-run.sh
./gradlew --quiet compileJava || exit 125     # untestable → skip
./gradlew --quiet test --tests '*CartTotalTest*'
Script exit code Meaning for bisect
0 this commit is good
1124, 126, 127 this commit is bad
125 this commit is untestable → behave like git bisect skip
≥ 128 abort the bisect (fatal error)

That 125 is gold: when the build does not compile at some commits, bisect skips them instead of drawing a wrong conclusion. With a history full of merge commits, --first-parent follows only the mainline and narrows the culprit to "which PR"; and good/bad can be renamed with --term-new/--term-old.

The hardest step is writing a deterministic reproducing test; bisect without a reliable signal is useless. Bisect is also why atomic, always-green commits matter — if half a team's commits do not build, bisect is worthless, which is itself an argument for squash-merge.


9. Tags and semantic versioning

git tag -a v1.4.2 -m "release 1.4.2"   # annotated: a real object (without -a: lightweight)
git tag -s v1.4.2 -m "release 1.4.2"   # annotated + signed; verify with -v
git push origin v1.4.2                 # tags are NOT pushed automatically!
git tag -d v1.4.2 && git push origin :refs/tags/v1.4.2   # delete locally and on the server
git describe --tags --dirty            # v1.4.2-7-gb858574

For releases always use annotated tags: git describe only sees annotated ones by default, so lightweight tags make build versioning scripts produce the wrong version. And never move a published tag — people who already fetched keep the old one; if you made a mistake, publish a new number.

SemVer 2.0.0 uses MAJOR.MINOR.PATCH: MAJOR for a breaking public-API change, MINOR for a backward-compatible feature, PATCH for a bug fix. A pre-release such as 1.5.0-rc.1 ranks below the final version, and build metadata such as 1.5.0+20260731 is ignored when comparing.

Its natural companion is Conventional Commits 1.0.0, a machine-readable convention: <type>[optional scope][!]: <description>, plus an optional body and footers.

feat(cart): apply coupon at checkout
fix(pricing): round tax to 2 decimals

feat(api)!: drop v1 endpoints

BREAKING CHANGE: /api/v1/* removed, migrate to /api/v2/*

Mapping to SemVer: fix → PATCH, feat → MINOR, ! or a BREAKING CHANGE: footer → MAJOR. That is what lets release tooling compute the version and changelog without a human.

Write the commit message for "the person six months from now": subject ≤ 72 characters, imperative mood ("add", not "added"), no full stop; a blank line; then a body answering why, not what — the what is readable from the diff, the why lives only in your head. For metadata there are trailers: git commit --trailer "Refs: PROJ-1421".


10. Remotes: fetch, pull and push without disaster

A remote is an alias for another repository's address; origin is a convention, nothing special. A refspec says which ref over there maps to which ref over here:

git remote show origin                     # full status, including stale branches
git config --get remote.origin.fetch
# +refs/heads/*:refs/remotes/origin/*

Read that refspec as "take every branch on the other side and put it under refs/remotes/origin/". The + means non-fast-forward updates are allowed — which is why origin/main moves without complaint when a colleague force-pushes.

Diagram: fetch only downloads; merge or rebase is what changes your work / نمودار: fetch فقط دانلود می‌کند و merge یا rebase کار تو را عوض می‌کند.

sequenceDiagram
  participant W as Working tree
  participant L as Local branch main
  participant R as Remote-tracking origin/main
  participant S as Server
  W->>L: git commit
  L->>S: git push
  S-->>R: git fetch updates origin/main
  R->>L: git merge origin/main
  R->>L: or git rebase origin/main
  Note over R,L: git pull = fetch + merge (or rebase)
git fetch --prune origin               # download + drop deleted remote-tracking refs
git log --oneline HEAD..origin/main    # what arrived that I do not have? (reverse it for unpushed)
git pull --rebase                      # = fetch + rebase

The configuration nearly every professional team sets:

git config --global pull.rebase true          # make pull rebase instead of merge
git config --global fetch.prune true
git config --global push.autoSetupRemote true # first push sets upstream automatically (Git 2.37+)
git config --global merge.conflictStyle zdiff3
git config --global diff.algorithm histogram  # more readable diffs for code
git config --global init.defaultBranch main
Default `git pull` is a factory for meaningless merge commits

Unconfigured, when your local branch and the remote have diverged, git pull creates a merge commit titled "Merge branch 'main' of ...". Ten of those in a week turn git log --graph into a fishing net.

Since Git 2.27, if pull.rebase is unset and history diverges, Git prints a warning and asks you to choose. For most teams the right answer is pull.rebase true, or pull.ff only.

git push --force                                   # ❌ never on a shared branch
git push --force-with-lease                        # ✅ only if the remote is where you think
git push --force-with-lease --force-if-includes    # ✅✅ the safest form
git config --global alias.pushf 'push --force-with-lease --force-if-includes'
Question: what is the difference between `--force` and `--force-with-lease`, and why was `--force-if-includes` added?

Answer: --force overwrites the server-side ref unconditionally; if a colleague pushed at that moment, their commit is removed from the branch (though the objects survive on the server until gc).

--force-with-lease adds an optimistic concurrency check: Git sends the expected value of the server-side ref (by default your local refs/remotes/origin/<branch>) and the server accepts only on a match — a compare-and-swap.

The remaining hole: the "expected value" may have been updated without your knowledge — an IDE fetching every minute, for instance. Then the lease matches your colleague's new work and your push is accepted even though you never saw it.

--force-if-includes closes that hole. Per the official documentation it "enables a check that verifies if the tip of the remote-tracking ref is reachable from one of the reflog entries of the local branch" — that is, the server's work really is integrated into your local work, not merely downloaded. On protected branches, force-push should also be disabled outright in the platform settings.


11. .gitignore, .gitattributes, hooks and large files

target/                 # build output
.env                    # environment and secrets
.idea/                  # developer tooling — better placed in ~/.config/git/ignore
!config/default.env     # exception

Precedence, weakest to strongest: $HOME/.config/git/ignore.gitignore in parent directories → .gitignore in the same directory → .git/info/exclude. A later pattern wins, and git check-ignore -v <path> names the rule and file responsible.

.gitignore has no effect on files that are **already tracked**

This is the single most common Git confusion. .gitignore only decides what stays untracked; a file committed once is tracked forever. The fix: git rm --cached .env (drop from the index, keep on disk), then add it to .gitignore.

The more serious warning: if that .env held a key, the key is still in history. The correct order is (1) immediately revoke and rotate the key, then (2) rewrite history with git filter-repo. Order matters — rewriting takes time, and the key is leaked meanwhile.

.gitattributes is under-appreciated but powerful:

* text=auto                        # LF in the repo, native line endings in the working tree
*.sh text eol=lf                   # shell scripts always LF, even on Windows
*.png binary                       # never diff or convert line endings
CHANGELOG.md merge=union           # keep both sides: no conflict
.github/ export-ignore             # excluded from release archives

merge=union is a genuine trick for append-only files such as CHANGELOG.md, which always conflict because everyone appends. Use it only where order and duplication are harmless; on code the result does not compile while the merge reports success.

Hooks

A hook is an executable script Git runs at specific lifecycle points. Their default home is .git/hooks/, which is never pushed; to share them set git config core.hooksPath .githooks, or use the pre-commit framework, which manages versions and installation from a .pre-commit-config.yaml.

Hook When it runs Common use
pre-commit before the commit is built lint, format, secret scanning
commit-msg after the message is written validate Conventional Commits
pre-push before sending fast tests, block pushes to main
pre-receive / update on the server, before accepting policy enforcement — unbypassable
A client-side hook is never a security control

Any developer can bypass every pre-commit and commit-msg hook with git commit --no-verify (short: -n), or git push --no-verify for pre-push. A client hook is a fast feedback loop, not a gate.

Every rule that matters must also run in CI and in server-side rules (rulesets / pre-receive): the same lint in the client hook for two-second feedback, and in CI as a required status check.

Submodules, subtrees and monorepos

A submodule is a Git repository inside another one, where the parent stores only a pointer to one specific commit; a subtree brings the other repository's content into your own history so cloning stays ordinary; a monorepo puts everything in one repository.

git submodule add https://example.org/team/lib.git libs/lib
git clone --recurse-submodules <url>
git submodule update --init --recursive       # if you forgot
git subtree add --prefix=libs/lib <url> main --squash
Submodules are a real attack surface — CVE-2025-48384

In July 2025, CVE-2025-48384 (CVSS 8.1) was published: when reading a configuration value Git stripped a trailing \r but did not quote it when writing. A crafted .gitmodules could check a submodule out at the wrong path; combined with a symlink and a post-checkout hook, the result was code execution from a plain git clone --recursive. It was added to the CISA KEV catalog (actively exploited) and fixed in 2.43.7, 2.44.4, 2.45.4, 2.46.4, 2.47.3, 2.48.2, 2.49.1 and 2.50.1. Lesson: keep Git updated and never clone an untrusted repository with --recursive.

The trade-off: submodules keep the version boundary explicit and the repository light, but everyone must learn extra commands and forgetting --recurse-submodules is common; subtrees keep cloning ordinary but inflate history and make contributing upstream harder; a monorepo enables atomic refactors across services but needs tooling:

git clone --filter=blob:none <url>            # partial clone: blobs arrive later
git clone --depth=1 <url>                     # shallow clone: good for CI
git sparse-checkout init --cone && git sparse-checkout set services/payments
scalar clone <url>                            # auto-configuration for large repositories

Large files and Git LFS

Git was built for small text files. A 200 MB video changed ten times makes the repository 2 GB forever, because history is never deleted. The standard answer is Git LFS (current stable line: 3.7.x): git lfs install && git lfs track "*.psd" writes the rule into .gitattributes (*.psd filter=lfs diff=lfs merge=lfs -text), after which Git stores a small pointer file instead of the content.

Three real traps: (1) platforms bill LFS storage and bandwidth against a quota, and CI that clones fully every time burns it — use GIT_LFS_SKIP_SMUDGE=1; (2) anyone without the LFS client sees pointer text instead of the file; (3) leaving LFS requires rewriting history. Before adopting LFS, ask whether the file belongs in version control at all, or in an artifact repository.


12. Four team workflows — and when to pick which

A workflow is the team's contract about which branches exist, how code travels and how a release is built. Let us compare four common patterns.

a) Git Flow — two permanent branches (main, develop) plus three temporary types

Diagram: Git Flow with two permanent and three temporary branch types / نمودار Git Flow با دو branch دائمی و سه نوع موقت.

flowchart LR
  F1["feature/x"] --> D["develop"]
  F2["feature/y"] --> D
  D --> R["release/1.4"]
  R --> M["main tagged v1.4.0"]
  R --> D
  H["hotfix/1.4.1"] --> M
  H --> D

Right when you ship numbered versions customers install (a library, a mobile app, on-premise software). Wrong for a SaaS deploying daily, where develop only doubles the merges.

b) GitHub Flow — a single always-deployable main

Diagram: GitHub Flow keeps a single always-deployable main / نمودار GitHub Flow با یک main همیشه سبز.

flowchart LR
  M0["main"] --> B["feature/short-lived"]
  B --> PR["Pull Request + CI + review"]
  PR --> M1["main merged"]
  M1 --> DEP["deploy to production"]

Right for a service that deploys constantly and has fast rollback; wrong when you must support two versions at once.

c) GitLab Flow — GitHub Flow plus environment branches

Diagram: GitLab Flow promotes code forward through environment branches / نمودار GitLab Flow و جریان رو به جلوی کد در محیط‌ها.

flowchart LR
  FT["feature branch"] --> MAIN["main"]
  MAIN --> STG["pre-production"]
  STG --> PRD["production"]
  HF["hotfix"] --> PRD
  HF --> MAIN

Right when deployment is scheduled, or compliance demands that "what is in production" be an auditable ref.

d) Trunk-Based — the branch lives in the code, not in Git

Everyone works on one trunk with sub-day branches, and unfinished work hides behind a feature flag.

Diagram: trunk-based moves the branch into the code, behind a flag / نمودار trunk-based و انشعاب پشت flag.

flowchart LR
  DEV["short-lived branch under 1 day"] --> TRUNK["main trunk"]
  TRUNK --> CI["CI on every commit"]
  CI --> ART["build artifact once"]
  ART --> ENVS["deploy to all environments"]
  FLAG["feature flag off by default"] --> ENVS

Right for a mature team with strong automated tests and feature flags. Wrong when coverage is weak — trunk-based without tests means broken production.

Criterion Git Flow GitHub Flow GitLab Flow Trunk-Based
Permanent branches main + develop main main + environments main
Multi-version support excellent poor good via release branches
Release cadence scheduled continuous gated many times a day
Feature flags needed rarely sometimes sometimes mandatory
Best for versioned products SaaS SaaS with compliance mature CD teams
Choosing a workflow is an engineering decision, not a preference

Ask three questions and the answer falls out: how many versions do you support at once? More than one → you need release/*. How often do you deploy? More than once a day → drop develop. Can you keep unfinished code behind a flag? If not, trunk-based will not work.

The common mistake: picking Git Flow for a SaaS "because it is the standard", and six months later develop and main are three weeks apart.

Question: our twelve-person team works on a SaaS, deploys weekly, and branches stay open for two weeks. What is wrong?

Answer: The core problem is long-lived branches, not the tool. The longer a branch stays open the further it drifts from main; conflicts grow exponentially and review degrades into one giant PR nobody reads seriously.

Fixes in priority order: (1) make the work smaller — under roughly 400 changed lines and less than two days per PR; split a big feature into independently mergeable PRs. (2) Add feature flags so half-finished code can sit in main without being active — that is what makes short branches possible. (3) Delete develop and move to GitHub Flow. (4) Require CI on every PR, plus a merge queue if the repository is busy. (5) Move deployment from weekly to continuous; smaller deploys carry less risk and create the incentive for short branches.


13. Pull requests and code review at a professional level

Review quality falls off a cliff with PR size: a two-thousand-line PR gets "LGTM", a 150-line PR gets a real opinion. Three tactics: separate refactors from behaviour changes; send mechanical changes such as renames on their own; and use stacked PRs, which git rebase --update-refs keeps maintainable.

Review axis What to check
Correctness boundary cases, null, errors, concurrency, transactions
Scope does the PR do only what its title claims?
Tests would the test have failed before the fix? is there a negative test?
Security is input validated? no secrets in code? is authorisation checked?
Performance and observability N+1 queries? enough logs and metrics to debug in production?

Shift the language of review from "you" to "the code": instead of "why didn't you null-check here?", write "what happens if order is null here?". And make severity explicit; many teams use prefixes: blocking: before merge, suggestion: optional, nit: taste, question: I want to understand — then the author knows what is holding up the merge.

Server-side gates

The rules that get enforced are the ones the platform enforces, not the ones in a wiki. Protected branches / rulesets: no direct pushes, required PRs, a minimum number of approvals, required green status checks, no force-push or branch deletion, required commit signing, and dismissal of approvals on every new push. Rulesets are the modern replacement: several apply at once, they are defined organisation-wide, and they carry bypass lists with an audit trail.

CODEOWNERS assigns an owner to each path and makes their approval mandatory:

# .github/CODEOWNERS  (or CODEOWNERS at the root or in docs/)
*                       @org/platform-team
/services/payments/**   @org/payments-team
/infra/**               @org/sre @org/security

The patterns behave like .gitignore and the last matching pattern wins — so put the general rule at the top and the specific ones below.

Merge queue: the platform lines PRs up, tests each on top of the previous result, and merges only if it stays green — the only scalable way to prevent "both PRs were green separately but together broke main".

Merge policy History on main When to choose it Risk
Merge commit full graph, feature boundary visible when you want to revert a whole feature at once busy graph
Squash merge linear, one commit per PR a good default for most teams; great for bisect intra-PR detail is lost; branch must be deleted
Rebase merge linear, all commits preserved when the team writes atomic commits history is rewritten; wip commits land on main
A squash policy is toxic with long-lived branches

If you close main with squash merges, make sure automatic branch deletion after merge is enabled. If someone keeps working on a merged branch, Git sees no ancestry between their commits and the squashed commit on main, and the next PR is a pile of repeated conflicts — which is why "Git Flow + squash merge" is almost always wrong.

Question: the team wants a guarantee that unreviewed code never reaches production. What layers do you put in place?

Answer: Defence in depth, because every single layer is bypassable:

  1. Server-side and unbypassable: a ruleset on main — no direct pushes, required PR with at least one approval (CODEOWNERS for sensitive paths), required CI status checks, no force-push or deletion, approvals dismissed on new pushes.
  2. Identity: a commit's author and committer fields are plain, entirely forgeable text; the only real answer is cryptographic signing:
git config --global gpg.format ssh
git config --global user.signingkey ~/.ssh/id_ed25519.pub
git config --global commit.gpgsign true
git config --global tag.gpgsign true
git log --show-signature -1
  1. CI as the gate: build, tests, lint, secret and dependency scanning as required status checks; plus a merge queue on a busy repository.
  2. Client hooks only for fast feedback, since --no-verify bypasses them.
  3. Audit: review the bypass log; constant bypassing means the rules do not match reality.

One caveat: a signature only proves "this key signed this object"; if the merge or squash happens on the server, the final commit is usually signed by the platform's key, not the author's.


14. Release branches and hotfixes

Diagram: hotfix flow — fix on the release line, then merge back to trunk / نمودار چرخهٔ hotfix و بازگرداندن آن به trunk.

sequenceDiagram
  participant P as production v2.4.0
  participant R as release/2.4
  participant M as main
  P->>R: incident reported
  R->>R: create hotfix/2.4.1 and fix
  R->>P: tag v2.4.1 and deploy
  R->>M: merge back or cherry-pick -x
  Note over R,M: never let the fix live only on the release branch
git switch -c hotfix/2.4.1 v2.4.0        # from the exact tag in production, not from main
git commit -m "fix(order): prevent NPE when coupon is null"
git tag -a v2.4.1 -m "hotfix 2.4.1"
git push origin hotfix/2.4.1 v2.4.1
git switch main && git cherry-pick -x <hotfix-sha>
The most common hotfix mistake: forgetting to bring it back to trunk

The fix goes onto release/2.4, production recovers — and three weeks later release 2.5 ships the same bug back to production because the fix never reached main. This is a resurrected regression.

Two guards: make merging or cherry-picking to trunk part of the definition of done for every hotfix; and have CI warn when a commit exists on release/* with no equivalent on main: git cherry -v main release/2.4 | grep '^+'.


15. Disasters and the exact recovery command

Disaster Recovery command
The last commit message is wrong git commit --amend (only if not pushed)
I ran git reset --hard by mistake git reflog, then git reset --hard HEAD@{1}
I deleted an unmerged branch git reflog / git fsck --lost-found, then git switch -c name <sha>
A bad commit is on main and pushed git revert <sha>; for a merge: git revert -m 1 <sha>
A rebase or merge got out of hand git rebase --abort / git merge --abort, or reset --hard ORIG_HEAD
A colleague force-pushed and my work is gone on your own machine git refloggit branch rescue <sha>
I committed an API key revoke the key first, then git filter-repo --invert-paths --path secrets.env + coordinated force-push

ORIG_HEAD is the point before the last merge/rebase/reset. Two more investigative tools: git log -L :calculateTotal:src/Cart.java shows how one function evolved, and git range-diff main old-feature new-feature compares two versions of the same commit series (before and after a rebase).

And a gift to your team's future: after a repo-wide reformat, git blame is broken forever unless you record that commit's hash in .git-blame-ignore-revs and set git config --global blame.ignoreRevsFile .git-blame-ignore-revs.

Question: someone ran `git push --force` on the shared `main` and two days of team work vanished. What do you do?

Answer: In order:

  1. Stop the bleeding: announce that nobody should pull or push; every new git pull may destroy a healthy local state.
  2. Find a good copy: ask someone who fetched recently for git reflog show origin/main; objects survive on the server until gc, so the platform can usually supply the previous hash from its event log.
  3. Restore: with a good hash, git branch rescue <sha>, then in coordination git push --force-with-lease origin rescue:main.
  4. Resynchronise the team: everyone stashes local work and runs git fetch origin && git reset --hard origin/main.
  5. Prevention, the most important part: force-push on main must be completely forbidden in server settings; the real problem is the repository configuration, not that developer.

A weak answer stops at step 3; a senior answer ends with "why was this even possible?".

Question: the repository has become slow after a few years; `git status` takes seconds. What do you do?

Answer: Measure first (git count-objects -vH, git rev-list --count --all), then treat in order of impact:

  1. git maintenance start, scheduling background tasks such as commit-graph, prefetch, loose-objects, pack-refs and incremental-repack; commit-graph alone makes history traversal several times faster.
  2. fsmonitor, which tracks changes through the OS watcher and makes git status dramatically faster on large trees — it existed on Windows and macOS, and Git 2.55 added Linux support via inotify.
  3. For huge repositories: git clone --filter=blob:none plus git sparse-checkout --cone, or simply scalar clone; in CI use --depth=1.

If the root cause is large historical files none of this is enough, and history must be rewritten with git filter-repo — a coordination event for the whole team.


16. Cheat sheet

Need Command
Compact status / graph git status -sb / git log --oneline --graph --decorate --all
Search text in history / follow a function git log -S "text" --all / git log -L :func:file
Clean up history git rebase -i --autosquash <base>
Safe update / safe force-push git pull --rebase / git push --force-with-lease --force-if-includes
Not pushed / not fetched git log --oneline @{u}..HEAD / HEAD..@{u}
Temporary drawer / find the culprit git stash push -u -m "msg" / git bisect run <script>
Repository maintenance git maintenance start

git worktree is the least known and most time-saving of these: when an urgent hotfix arrives mid-feature you need not stash — git worktree add ../project-hotfix -b hotfix/2.4.1 v2.4.0 gives a second working directory backed by the same repository. Objects are shared, so it costs almost no space and each keeps its own build cache.

Question: explain Git's object model in 90 seconds.

Answer: Git is a content-addressable key-value database. The key is the SHA-1 of <type> <size>\0<content> and the value is the object itself. There are four object types: blob (file content, no name or permissions), tree (a directory: a list of mode type sha name entries pointing at blobs and other trees), commit (a root tree + zero or more parents + author + committer + message) and annotated tag (a named, signable pointer to an object).

On top of that immutable graph sits the ref layer: refs/heads/* for branches, refs/tags/* for tags, refs/remotes/* for remote-tracking refs, and HEAD, usually a symbolic ref to a branch. The index is a separate binary file building "the next commit".

Three consequences fall out of this model: branches are cheap because they are 41-byte files; history is tamper-evident because any change alters every downstream hash; and rewriting history always means creating new objects, never editing old ones — exactly why the golden rule of rebase exists.

In closing

Git is a content-addressable database of immutable objects — blob for content, tree for directories, commit for snapshots, tag for naming — with a thin ref layer on top and HEAD telling you where you stand. Every commit is a full picture, not a diff, and the three trees model makes every command derivable.

merge preserves history and rebase rewrites it — so rebase only work that is still yours. revert is the only safe way to undo something pushed, reflog recovers almost anything within a 30-to-90-day window, and bisect run finds the culprit in a handful of steps.

At the team level, pick a workflow with three questions — how many versions you support, how often you deploy, whether you have feature flags — and know that short branches and small PRs raise quality more than any tool. Enforce the real rules server-side, because client hooks are bypassed with --no-verify. Finally, a few habits: --force-with-lease --force-if-includes instead of -f, an annotated tag per release, zdiff3 for conflicts, and git maintenance start on large repositories.