Git 版本控制 › 第 4 章

日常操作流程

2026-06-16  ·  init · clone · status · add · commit · log · diff · .gitignore

前三章把心智模型搭好了:物件圖(第 2 章 blob/tree/commit)+指標(第 3 章 branch/HEAD)。本章回到地面,看你每天真的會敲的那幾個指令——initstatusaddcommitlogdiff——它們究竟在三大區域那張圖上做了什麼。看懂之後,git status 印出的每一行對你都不再是黑話,而是「目前三個區域各裝著哪個版本」的精確報告。

4.1initclone:版本庫從哪來

一切的起點是建立一個版本庫。有兩條路:從無到有用 git init,從別人那裡複製一份用 git clone

git init 做的事少得驚人:它在當前資料夾裡新增一個 .git/ 子目錄,就結束了。你的檔案一個都沒被動到,也還沒有任何 commit。那個 .git/ 就是第 2 章看過的整座版本庫——物件庫、refs、HEAD 全在裡面:

git init 後 .git 目錄的骨架 git init 在專案資料夾建立 .git 目錄,內含 objects、refs/heads、HEAD、config、index 等 my-project/ app.js  README.md  (你的工作目錄檔案,init 不碰) .git/ ← 整座版本庫就是這一個資料夾 objects/    所有 blob/tree/commit(目前是空的) refs/heads/ 分支指標(還沒有任何分支檔) HEAD       ref: refs/heads/main(指向尚不存在的 main) config  index  設定檔、暫存區索引(4.2 主角)
圖 4-1init 只生出一個 .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/,你備份到的就只是「某一刻的檔案」,沒有歷史。

4.2statusadd:檔案狀態與暫存區

有了版本庫,日常就是「改檔 → 看狀態 → 挑要記錄的 → 提交」的循環。要看懂這循環,得先回答一個常見問題:工作目錄裡的檔案,在 Git 眼中有哪些狀態?

檔案的四種狀態

Git 把工作目錄裡的每個檔案分成這幾種狀態。關鍵分水嶺是「Git 有沒有在追蹤它」:

狀態意思status -s 代碼
未追蹤 Untracked新檔,Git 從沒記錄過它??
未修改 Unmodified已追蹤,和上次 commit 完全一樣 (乾淨,不顯示)
已修改 Modified已追蹤,改過但還沒 add M
已暫存 Staged改動已放進暫存區,等著進下一顆 commitM

這四種狀態會隨你的動作流轉。下圖把整個循環畫出來——注意 git add 往右推、git commit 收口、編輯檔案則往回拉:

檔案四種狀態的流轉 未追蹤經 add 變已暫存;未修改經編輯變已修改,經 add 變已暫存,經 commit 回到未修改 未追蹤 Untracked 未修改 Unmodified 已修改 Modified 已暫存 Staged git add 編輯 git add git commit
圖 4-2檔案在四種狀態間流轉:add 往暫存區推、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,乾淨待提交
MMMMadd 之後又改了——暫存的是舊版,工作目錄有更新版
A A空白新檔已 add(第一次 add 用 A=Added)
MM 是最能說明「暫存區是一張獨立快照」的情況:你 add 的是當下那一刻的內容;之後再改檔,新改動不會自動跟進暫存區。要它進下一顆 commit,得再 add 一次。這點在下面講暫存區機制時會更清楚。

暫存區到底是什麼:.git/index

「暫存區(staging area)」聽起來抽象,但它有個非常具體的真身——.git/index 這個檔。它不是一個資料夾、也不複製檔案內容,而是一張清單,為每個被追蹤的檔案存一列:

git add 把 blob 存進物件庫,並更新 index 的一列 工作目錄的檔案經 git add,內容算成 blob 存入 objects,index 記下路徑、blob SHA、stat 資訊 工作目錄 app.js (實際檔案內容) .git/index(暫存區) 每列 = 一個檔案: 路徑: app.js blob SHA: 3b18e5… stat: 大小/時間/權限 (只存指標,不存內容) .git/objects blob 3b18e5… (壓縮後的內容) ① 存 blob 進物件庫 ② 更新 index 那一列
圖 4-3git add 兩步:把內容算成 blob 存進物件庫(第 2 章),再把該檔在 index 的那列指向這個 blob SHA

所以 git add app.js 的真相是:

  1. 讀工作目錄裡 app.js 的當前內容,算出它的 blob hash,把這個 blob 寫進 .git/objects(若已存在就略過——內容定址的好處)。
  2. 更新 indexapp.js 那一列:路徑 + 剛算出的 blob SHA + stat 資訊(檔案大小、修改時間等,讓 status 下次能用「stat 沒變」快速判斷檔案沒動,不必每次重算 hash)。
index 想成「下一棵 tree 的草稿」:它記著「下次 commit 時,專案的每個檔案各該指向哪個 blob」。你每 add 一次,就是在編輯這份草稿。等到 commit,Git 就把這份草稿定形成一棵真正的 tree(第 2 章)——這正是下一節的主題。也因此暫存區讓你能挑選要提交哪些改動:改了五個檔,只 add 其中兩個,commit 就只含那兩個。

延伸問答:退出暫存區(unstage)

延伸問答

怎麼把一個檔案從 Staged 退回 Modified?(add 的逆操作)

兩個等價指令,擇一:

git restore --staged <file>   # Git 2.23+,現代寫法,推薦
git reset HEAD <file>         # 舊式,效果相同(HEAD 可省略)

用 4.2 的 index 模型看:它把 index 裡那一列還原成 HEAD(上次 commit)的 blob,而工作目錄完全不動。於是該檔工作目錄內容仍和 HEAD 不同 → statusM 變回 M。本質就是「把下一棵 tree 的草稿裡那一列,還原成 commit 的版本」,你的檔案一個字都沒被碰。

別和「丟棄改動」搞混:git restore --staged <file> 只退暫存,工作目錄的改動還在;git restore <file>(不加 --staged)會連工作目錄的改動也還原掉——那會動到你的檔案,屬第 9 章「回溯與救援」。
延伸問答

如果檔案本來是 Untracked,退暫存會退回 Untracked 嗎?

會,退回 Untracked,不是 Modified。路徑是 ??addA restore --staged??。原因正好把 index 模型講透:這是個全新檔案,HEAD 裡根本沒有它,「把 index 那列還原成 HEAD 的版本」=「還原成一個不存在的版本」=把那列整個移出 index。結果是 index 沒這列、檔案仍在工作目錄 = Untracked

關鍵心智模型:「Modified」是相對於一個已 commit 的基準才成立的。所以退暫存退回哪裡,取決於有沒有 commit 基準——這也解釋了 4.2 表格裡,為什麼新檔暫存後的碼是 A (Added)而非 M :它是「新增」,沒有舊版可言,談不上 modified。
退暫存的去向依有無 commit 基準而分岔 已暫存的檔案執行 restore --staged 後,原本是新檔的退回未追蹤、原本已追蹤的退回已修改 已暫存 M / A git restore --staged 退回哪裡?看有沒有 commit 基準 原本是新檔(無基準) 原本已追蹤(有基準) 未追蹤 ?? 那列被移出 index 已修改  M 那列還原成 commit 版
圖 4-4restore --stagedadd 的逆操作,但退回的狀態依有無 commit 基準分岔:新檔回未追蹤、已追蹤的回已修改

4.3commit:把暫存區釘成一顆快照

當暫存區(index)這份草稿就緒,git commit 把它定形。回想第 2、3 章,你已經知道每個零件,這裡只是把它們串起來。git commit -m "訊息" 做的事:

  1. 把目前的 index 寫成一棵(或多棵巢狀的)tree 物件——這就是這顆 commit 的「根目錄快照」。
  2. 用 HEAD 找到目前分支指的 commit,當作新 commit 的 parent
  3. 建立 commit 物件(指向那棵 tree + parent + 作者 + 時間 + 你打的訊息),算出它的 hash。
  4. 目前分支指標移到這顆新 commit;HEAD 黏著分支一起前進(第 3 章那串連鎖)。
三大區域與 add/commit 的對應 工作目錄經 add 進暫存區,暫存區經 commit 成為版本庫裡的一顆 commit,HEAD 與分支前移 工作目錄 你編輯的檔案 暫存區 index 下一棵 tree 草稿 版本庫 .git P N 新 commit ← HEAD add commit
圖 4-5add 把工作目錄改動推進暫存區;commit 把暫存區定形成新 commit(N),分支與 HEAD 前移,N 的 parent 是原本的 P

幾個每天會用到的變體:

指令作用
git commit -m "訊息"提交目前暫存區,訊息寫在命令列
git commit不帶 -m,開編輯器讓你寫(多行)訊息
git commit -a -m "訊息"-a = 提交前自動 add 所有已追蹤檔案的改動(但不含未追蹤新檔)
commit -a 很方便,但別養成無腦習慣:它跳過了「挑選要提交什麼」這一步,容易把不相干的改動一起塞進同一顆 commit。未追蹤的新檔它也不會收——那些仍得先手動 add。乾淨的歷史來自有意識的暫存。

4.4logdiff:讀歷史、看差異

提交之後,你需要兩種「閱讀」能力: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 Bcommit A vs commit B這兩顆 commit 之間差什麼?
很多人「改完檔、add 了、再打 git diff 卻看到空白」就以為出錯——其實正確!git diff 比的是工作目錄 vs 暫存區,你剛 add 過,這兩邊一樣,當然沒差異。想看已暫存的改動要用 git diff --staged。這正好印證 4.2「暫存區是獨立一層」。

讀 diff 輸出:- 開頭的行(常顯紅)是移除,+ 開頭(常顯綠)是新增,修改一行 = 一刪一加;@@ -a,b +c,d @@ 標示變動落在第幾行附近。

4.5.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 自己要 addcommit,團隊才共用同一份忽略規則
最痛的教訓通常是 .env 或密鑰先被 commit、事後才加進 .gitignore——這時祕密已經在歷史裡了,ignore 完全救不回。它只防未來,擋不了過去。真的提交了敏感資料,得當作已外洩(換掉金鑰),並用改寫歷史的工具清除(超出本章;先記住「ignore 不等於刪歷史」)。

4.6互動:指令 → 狀態變化

把本章串起來:按指令,看一個檔案 app.js 怎麼在工作目錄暫存區版本庫三區之間流動,以及 git status -s 怎麼變。每格顯示該區域目前持有的版本(v1、v2…),三區版本一致就代表乾淨。

工作目錄
你正在編輯的檔案
暫存區 index
下一棵 tree 草稿
版本庫 .git
已提交的快照
試這個流程體會「暫存的是快照」:① 按 編輯 → ② git add(出現 A :新檔已暫存)→ ③ 再按一次 編輯。狀態變成 AM——A=暫存區有這個新檔(v1)、M=工作目錄又改成了 v2。這正印證 4.2:add 抓的是當下快照,之後的改動不會自動跟進,要再 add 一次才會進下一顆 commit。
(若檔案已 commit 過再走「編輯→add→編輯」,左欄就不是 A 而是 M,顯示 MM——同一個道理。)

4.7本章小結

指令一句話記住
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三方合併再會合。