2026-06-16 · init · clone · status · add · commit · log · diff · .gitignore
init、status、add、commit、log、diff——它們究竟在三大區域和那張圖上做了什麼。看懂之後,git status 印出的每一行對你都不再是黑話,而是「目前三個區域各裝著哪個版本」的精確報告。
init 與 clone:版本庫從哪來一切的起點是建立一個版本庫。有兩條路:從無到有用 git init,從別人那裡複製一份用 git clone。
git init 做的事少得驚人:它在當前資料夾裡新增一個 .git/ 子目錄,就結束了。你的檔案一個都沒被動到,也還沒有任何 commit。那個 .git/ 就是第 2 章看過的整座版本庫——物件庫、refs、HEAD 全在裡面:
.git/;此時 HEAD 已指向 main,但 main 這顆 commit 還不存在(所謂「unborn branch」)git clone <url> 則是把遠端整座 .git/ 抓下來(所有 commit、所有歷史,呼應第 1 章「每個 clone 都是完整版本庫」),再額外做兩件事:把預設分支檢出到工作目錄(於是你看到檔案),並記住來源位址叫 origin(第 8 章主角)。
| 指令 | 做了什麼 | 之後狀態 |
|---|---|---|
git init | 建立空的 .git/ | 0 個 commit、工作目錄原封不動 |
git clone <url> | 複製整座版本庫 + 檢出 + 設定 origin | 完整歷史、工作目錄已是最新版 |
.git/ 這個資料夾,就等於把版本控制整個拿掉——所有 commit 歷史瞬間消失,只剩當下的檔案(變回一堆普通檔案)。反過來,備份專案時若漏掉隱藏的 .git/,你備份到的就只是「某一刻的檔案」,沒有歷史。
status 與 add:檔案狀態與暫存區有了版本庫,日常就是「改檔 → 看狀態 → 挑要記錄的 → 提交」的循環。要看懂這循環,得先回答一個常見問題:工作目錄裡的檔案,在 Git 眼中有哪些狀態?
Git 把工作目錄裡的每個檔案分成這幾種狀態。關鍵分水嶺是「Git 有沒有在追蹤它」:
| 狀態 | 意思 | status -s 代碼 |
|---|---|---|
| 未追蹤 Untracked | 新檔,Git 從沒記錄過它 | ?? |
| 未修改 Unmodified | 已追蹤,和上次 commit 完全一樣 | (乾淨,不顯示) |
| 已修改 Modified | 已追蹤,改過但還沒 add | M |
| 已暫存 Staged | 改動已放進暫存區,等著進下一顆 commit | M |
這四種狀態會隨你的動作流轉。下圖把整個循環畫出來——注意 git add 往右推、git commit 收口、編輯檔案則往回拉:
git status -s:看懂那兩欄代碼精簡模式 git status -s(或 --short)每行開頭有兩個字元欄位,很多人看不懂——其實它直接對應第 1 章的兩個區域:
XY 檔名
│└─ Y = 工作目錄 vs 暫存區 的差異(尚未 add 的改動)
└── X = 暫存區 vs 上次 commit 的差異(已 add、待提交)
| 顯示 | X(暫存區欄) | Y(工作目錄欄) | 解讀 |
|---|---|---|---|
| ?? | 兩欄皆 ? | 未追蹤的新檔 | |
| M | 空白 | M | 改了但還沒 add |
| M | M | 空白 | 改動已 add,乾淨待提交 |
| MM | M | M | add 之後又改了——暫存的是舊版,工作目錄有更新版 |
| A | A | 空白 | 新檔已 add(第一次 add 用 A=Added) |
MM 是最能說明「暫存區是一張獨立快照」的情況:你 add 的是當下那一刻的內容;之後再改檔,新改動不會自動跟進暫存區。要它進下一顆 commit,得再 add 一次。這點在下面講暫存區機制時會更清楚。
.git/index「暫存區(staging area)」聽起來抽象,但它有個非常具體的真身——.git/index 這個檔。它不是一個資料夾、也不複製檔案內容,而是一張清單,為每個被追蹤的檔案存一列:
git add 兩步:把內容算成 blob 存進物件庫(第 2 章),再把該檔在 index 的那列指向這個 blob SHA所以 git add app.js 的真相是:
app.js 的當前內容,算出它的 blob hash,把這個 blob 寫進 .git/objects(若已存在就略過——內容定址的好處)。index 裡 app.js 那一列:路徑 + 剛算出的 blob SHA + stat 資訊(檔案大小、修改時間等,讓 status 下次能用「stat 沒變」快速判斷檔案沒動,不必每次重算 hash)。index 想成「下一棵 tree 的草稿」:它記著「下次 commit 時,專案的每個檔案各該指向哪個 blob」。你每 add 一次,就是在編輯這份草稿。等到 commit,Git 就把這份草稿定形成一棵真正的 tree(第 2 章)——這正是下一節的主題。也因此暫存區讓你能挑選要提交哪些改動:改了五個檔,只 add 其中兩個,commit 就只含那兩個。
怎麼把一個檔案從 Staged 退回 Modified?(add 的逆操作)
兩個等價指令,擇一:
git restore --staged <file> # Git 2.23+,現代寫法,推薦
git reset HEAD <file> # 舊式,效果相同(HEAD 可省略)
用 4.2 的 index 模型看:它把 index 裡那一列還原成 HEAD(上次 commit)的 blob,而工作目錄完全不動。於是該檔工作目錄內容仍和 HEAD 不同 → status 從 M 變回 M。本質就是「把下一棵 tree 的草稿裡那一列,還原成 commit 的版本」,你的檔案一個字都沒被碰。
git restore --staged <file> 只退暫存,工作目錄的改動還在;git restore <file>(不加 --staged)會連工作目錄的改動也還原掉——那會動到你的檔案,屬第 9 章「回溯與救援」。
如果檔案本來是 Untracked,退暫存會退回 Untracked 嗎?
會,退回 Untracked,不是 Modified。路徑是 ?? → add → A → restore --staged → ??。原因正好把 index 模型講透:這是個全新檔案,HEAD 裡根本沒有它,「把 index 那列還原成 HEAD 的版本」=「還原成一個不存在的版本」=把那列整個移出 index。結果是 index 沒這列、檔案仍在工作目錄 = Untracked。
restore --staged 是 add 的逆操作,但退回的狀態依有無 commit 基準分岔:新檔回未追蹤、已追蹤的回已修改commit:把暫存區釘成一顆快照當暫存區(index)這份草稿就緒,git commit 把它定形。回想第 2、3 章,你已經知道每個零件,這裡只是把它們串起來。git commit -m "訊息" 做的事:
index 寫成一棵(或多棵巢狀的)tree 物件——這就是這顆 commit 的「根目錄快照」。add 把工作目錄改動推進暫存區;commit 把暫存區定形成新 commit(N),分支與 HEAD 前移,N 的 parent 是原本的 P幾個每天會用到的變體:
| 指令 | 作用 |
|---|---|
git commit -m "訊息" | 提交目前暫存區,訊息寫在命令列 |
git commit | 不帶 -m,開編輯器讓你寫(多行)訊息 |
git commit -a -m "訊息" | -a = 提交前自動 add 所有已追蹤檔案的改動(但不含未追蹤新檔) |
commit -a 很方便,但別養成無腦習慣:它跳過了「挑選要提交什麼」這一步,容易把不相干的改動一起塞進同一顆 commit。未追蹤的新檔它也不會收——那些仍得先手動 add。乾淨的歷史來自有意識的暫存。
log 與 diff:讀歷史、看差異提交之後,你需要兩種「閱讀」能力:log 看歷史(commit 之間),diff 看差異(區域之間或 commit 之間)。
git log:沿著 parent 往回走git log 本質就是第 3 章那條 DAG 的文字呈現:從 HEAD 出發,順著 parent 指標一路往回印。幾個高頻用法:
| 指令 | 用途 |
|---|---|
git log | 完整列出每顆 commit(hash、作者、日期、訊息) |
git log --oneline | 每顆 commit 縮成一行(短 hash + 訊息),看全局最常用 |
git log --oneline --graph --all | 用 ASCII 線條畫出分支/合併結構,看所有分支 |
git log -p | 連每顆 commit 的改動內容(diff)一起印 |
git log -3 / git log <檔名> | 只看最近 3 顆 / 只看動過某檔的 commit |
$ git log --oneline
a1b2c3d (HEAD -> main) 修正登入頁排版
9f8e7d6 新增使用者設定頁
3c2b1a0 初始 commit
git diff:它預設比的是哪兩邊?diff 最容易搞混的點是「到底在比什麼跟什麼」。記住它就是在比較三大區域裡的兩個——同一檔案的不同版本:
| 指令 | 比較 | 回答的問題 |
|---|---|---|
git diff | 工作目錄 vs 暫存區 | 我改了、但還沒 add 的部分? |
git diff --staged | 暫存區 vs 上次 commit | 我已 add、下次會提交的部分? |
git diff HEAD | 工作目錄 vs 上次 commit | 自上次提交以來所有改動(不管 add 沒)? |
git diff A B | commit A vs commit B | 這兩顆 commit 之間差什麼? |
add 了、再打 git diff 卻看到空白」就以為出錯——其實正確!git diff 比的是工作目錄 vs 暫存區,你剛 add 過,這兩邊一樣,當然沒差異。想看已暫存的改動要用 git diff --staged。這正好印證 4.2「暫存區是獨立一層」。
讀 diff 輸出:- 開頭的行(常顯紅)是移除,+ 開頭(常顯綠)是新增,修改一行 = 一刪一加;@@ -a,b +c,d @@ 標示變動落在第幾行附近。
.gitignore:叫 Git 別管哪些檔案有些檔案你不想納入版本控制:編譯產物、相依套件資料夾(node_modules/)、密鑰、本機設定、編輯器暫存檔。.gitignore 是一個放在專案裡的純文字檔,列出要忽略的樣式;符合的檔案就會被 status 略過、也不會被一般的 git add 收進來。
# 註解以 # 開頭
node_modules/ # 結尾 / = 只忽略資料夾
*.log # * = 萬用字元,忽略所有 .log
.env # 忽略特定檔(常見:密鑰)
dist/ # 編譯產物
!important.log # ! = 例外,這個別忽略
關鍵在搞懂它的作用邊界——下面這三點是最常見的坑:
| 規則 | 說明 |
|---|---|
| 只擋未追蹤檔 | .gitignore 只對「Git 還沒追蹤的檔」生效 |
| 已追蹤的擋不住 | 檔案一旦被 commit 過,寫進 ignore 也沒用;要先 git rm --cached <檔> 把它移出追蹤(留著本機檔案),再 commit |
| 本身要進版控 | .gitignore 自己要 add、commit,團隊才共用同一份忽略規則 |
.env 或密鑰先被 commit、事後才加進 .gitignore——這時祕密已經在歷史裡了,ignore 完全救不回。它只防未來,擋不了過去。真的提交了敏感資料,得當作已外洩(換掉金鑰),並用改寫歷史的工具清除(超出本章;先記住「ignore 不等於刪歷史」)。
把本章串起來:按指令,看一個檔案 app.js 怎麼在工作目錄、暫存區、版本庫三區之間流動,以及 git status -s 怎麼變。每格顯示該區域目前持有的版本(v1、v2…),三區版本一致就代表乾淨。
編輯 → ② git add(出現 A :新檔已暫存)→ ③ 再按一次 編輯。狀態變成 AM——A=暫存區有這個新檔(v1)、M=工作目錄又改成了 v2。這正印證 4.2:add 抓的是當下快照,之後的改動不會自動跟進,要再 add 一次才會進下一顆 commit。| 指令 | 一句話記住 |
|---|---|
init / clone | 建空 .git/ / 複製整座版本庫 + 檢出 + 設 origin |
status -s | 兩欄碼:X=暫存區 vs commit、Y=工作目錄 vs 暫存區;?? 未追蹤、MM add 後又改 |
add | 存 blob 進物件庫 + 更新 index 那列;index = 下一棵 tree 的草稿 |
commit | 把 index 定形成 tree + commit 物件,分支與 HEAD 前移 |
log | 沿 parent 往回印歷史;--oneline --graph 看結構 |
diff | 預設比 工作目錄 vs 暫存區;--staged 比暫存區 vs commit |
.gitignore | 只擋未追蹤檔;已追蹤的、已進歷史的都擋不住 |
git status 的每一行都讀得懂了。但到目前為止我們都只在一條分支上前進。下一章正式分岔:branch / switch 怎麼開分支,以及兩條線怎麼透過 fast-forward 與 三方合併再會合。