# 用 git log 讀慣例，用 git reset --soft 重切 — 在推送前把提交整理成該有的形狀

> 提交形狀要先讀過儲存庫的歷史紀錄才能決定。用 git reset --soft，可以把尚未推送的提交重切到符合 git log 顯示的樣子，重切幾次都可以。

- Source: https://oharu121.com/zh-tw/blog/git-commit-shape-log-convention-reset-soft-recut/
- Published: 2026-08-28T00:11:19+09:00
- Tags: Git, 開發工具

---
**重點摘要**

- 提交形狀是儲存庫的性質，不是 diff 的性質。同樣的變更，在一個儲存庫要拆成三個提交，在另一個儲存庫卻只要一個。
- `git log --oneline` 就是規格書。在 git add 任何東西之前先讀它。
- 尚未推送的提交只是素材。`git reset --soft <base>` 會把它們收回成已暫存的樹狀結構，不會動到任何一個檔案。
- 打亂計畫的限制是 `.gitignore`，在檔案沒有出現在 `git status` 之前，完全看不出來。
- 要不要開分支，是跟提交形狀分開的另一個決定，而歷史紀錄同樣會告訴你答案。

## 引言

在一次長時間工作階段的尾聲，代理人針對我們一起完成的變更，提出了一個三個提交的計畫。但最後落地的卻是單一個提交。這個選擇做起來很容易，解釋起來卻很難，這中間的落差讓我很在意：我當下用的那條規則，自己竟然說不出來。

大家都在講的建議是「把工作拆成有邏輯的提交」。這話沒錯，但不夠用。它只告訴你一個提交應該要有意義，卻沒告訴你眼前這堆變更該拆成幾個提交，接縫要切在哪裡。兩個儲存庫就算 diff 完全相同，想要的答案也可能完全不一樣。

規則其實很簡單。**形狀來自 `git log`，而在推送之前讓你能改變主意的，是 `git reset --soft`。**這篇文章會走過五個步驟，做出一份符合所在儲存庫慣例的提交歷史。

*Figure — FiveSteps: 第 3 步是會把你送回頭的步驟，這也是為什麼第 4 步一定要夠便宜。*

## 為什麼提交形狀屬於儲存庫

diff 本身對於該怎麼切分沒有意見。有意見的是專案：它的發布流程、票務系統、審查習慣，還有有沒有人會對它跑 `git bisect`。

想像兩個專案。一個是函式庫，`main` 很活躍，貢獻者有數十人，歷史紀錄都是小而聚焦的提交。在這裡把一個變更拆成三個提交，顯然是對的做法，因為每一個都會在 pull request 裡被單獨閱讀。另一個專案以版本化的發布出貨，每一次發布都是單一個提交，訊息裡帶著票號。**在第二個專案裡整整齊齊地拆成三份，不是更好的做法，而是雜訊。**而且這麼拆會破壞發布、提交、票號三者之間的一對一對應關係，而整套流程就是靠這個對應關係在運作。

*Figure — HistoryToShape: 兩欄的 diff 完全相同，不同的是各自落地的歷史紀錄。*

這兩個儲存庫都沒有把這件事寫成文件。但兩者都把它記錄在唯一一個不會過時的地方。

## 第 1 步：先讀歷史紀錄，再開始暫存任何東西

第一個要打的指令不是 `git add`，而是：

```bash
git log --oneline -5
```

我當時作業的專案，輸出大致是這樣（細節已經一般化）：

```text
a1b2c3d feat(prompts): apply the glossary and add evaluation questions (PROJ-142)
e4f5a6b fix(pipeline): correct a write-back defect and refresh the source data (PROJ-141)
b7c8d9e fix(prompts): prevent arithmetic errors and define the aggregation range (PROJ-139)
1a2b3c4 feat(pipeline): add a deterministic accuracy test series (PROJ-138)
5d6e7f8 fix(prompts): correct a syntax error and add reviewer checks (PROJ-137)
```

從這五行可以讀出四件事，而且沒有一件寫在儲存庫的其他地方：

| 要看的地方 | 這份歷史紀錄說了什麼 |
| --- | --- |
| 每個工作單位的提交數 | 一個。每一行就是一整次發布。 |
| 訊息格式 | Conventional Commits，日文說明，結尾帶票號。 |
| 票號 | 必要，格式是主旨結尾的 `(PROJ-NNN)`。 |
| 合併提交 | 沒有，所以分支是被折回去而不是合併進來。 |

**這張表就是規格書。**接下來四個步驟做的所有事，都是為了滿足它。

## 第 2 步：提出一個形狀，並替每個提交的用途命名

掌握慣例之後，代理人提出了三個提交，依照各自碰到的產出類型分組：資料與設定檔、提示詞檔案，以及文件。我核准了這個分組方式，接著開始暫存。

這個階段有用的測試，不是分組看起來整不整齊，而是**你能不能用一句話說清楚每個提交是做什麼的，讓一個當時不在場的讀者也聽得懂。**說不出來的提交，要嘛該拆成兩個，要嘛根本不該存在。這是標準建議裡，真正碰到實際儲存庫之後還撐得住的部分，值得留著。

標準建議沒講到的是，這個階段提出的方案只是暫定的。當時還有兩件事沒檢查。

## 第 3 步：找出你不被允許提交的東西

暫存第三個提交時什麼都沒發生。`git status --short` 完全沒列出那些檔案：

```bash
git status --short
```

原因一個指令就查出來了：

```bash
git check-ignore -v docs/draft-report.md output/question-set.xlsx
```

```text
.gitignore:17:docs/	docs/draft-report.md
.gitignore:13:output/	output/question-set.xlsx
```

這兩個目錄早在這次工作階段之前，就被刻意忽略了。**第三個提交沒有內容，而且從一開始就不可能有內容。**計畫從三個提交變成兩個，原本要放進去的產出，其實根本不在版本控制的範圍內。

還有第二個限制，比較不容易察覺。工作目錄裡有兩個修改過的檔案，是在這次工作階段開始之前，被別人先前的工作改動過的。代理人沒有動過它們，所以把它們挑出來標記出來，而不是用 `git add -A` 一併掃進去。**你能提交的範圍，受限於你實際改動過的範圍**，而一個跑了好幾個小時的工作階段，正是這條界線最容易被忘記的時候。

這兩個限制都是在形狀被提出之後才出現的。這個先後順序不是該避免的失誤，而正是下一步存在的理由。

## 第 4 步：用 `git reset --soft` 重切

到這個時候，已經有兩個提交存在，是在注意到票號是必要項目之前寫的，而歷史紀錄要的是一個提交，不是兩個。兩個都還沒推送。

在推送之前，提交都還只是草稿，而 `git reset --soft` 就是把它當草稿對待的工具：

```bash
git reset --soft <base-sha>
```

讓這件事安全的正是這個旗標。`--soft` 會把分支指標移回指定的提交，然後停在那裡。索引會保留原本暫存的所有內容，工作目錄不受影響，**磁碟上沒有一個檔案會改變。**消失的只有提交之間的邊界。被丟棄的提交裡的內容全部都還暫存著，隨時可以照你現在想要的安排重新提交。

*Figure — SoftReset: 提交這一欄變了，工作目錄這一欄沒變。正是這種不對稱，讓這個操作可以放心重複做。*

接下來重切只需要兩個指令：

```bash
git add -A
git commit
```

結果是一個提交，裝著這次工作階段的成果，也帶著歷史紀錄要求的票號：

```bash
git log --oneline -3
```

```text
9f8e7d6 fix(prompts): align evaluation questions with the reviewer's vocabulary (PROJ-143)
a1b2c3d feat(prompts): apply the glossary and add evaluation questions (PROJ-142)
e4f5a6b fix(pipeline): correct a write-back defect and refresh the source data (PROJ-141)
```

同一套機制反過來也能用。要把一個提交拆成好幾個，就用 `git reset --soft HEAD~1`，再用 `git add -p` 分批暫存。合併（squash）和拆分其實是同一個操作，差別只在後續怎麼做，這也是為什麼「該拆還是該合併」這個問題，背後只有同一個工具在支撐兩種答案。

這一切的界線就是「有沒有公開」。一旦提交被推送到別人會在上面繼續開發的分支，改寫它就不再是沒有成本的事。**這個步驟裡的所有操作之所以安全，正是因為沒有任何東西離開過這台機器。**

## 第 5 步：落地，並決定到底要不要開分支

代理人在提交任何東西之前，先開了一個分支：

```bash
git switch -c fix/question-vocabulary
```

`git switch` 本身就值得認識。它是在 Git 2.23 加入的，接手了 `git checkout` 裡切換分支的那一半工作。`git checkout` 原本身兼兩個不相關的任務：在分支之間移動，以及還原檔案。`git switch -c <name>` 只會建立並切換到一個分支，不會偷偷對你的檔案做任何事，因為還原檔案現在是 `git restore` 的工作。

會開分支，是因為代理人的預設行為是避免直接對儲存庫的主線提交。這是合理的預設值，但在這裡是錯誤的判斷，而歷史紀錄早就講過了：完全沒有合併提交，每一次發布都是直接落在主分支上。我選擇把它折回去：

```bash
git switch main
git merge --ff-only fix/question-vocabulary
git branch -d fix/question-vocabulary
```

`--ff-only` 在這串指令裡做的是實質的工作。它拒絕建立合併提交，所以如果分支沒辦法快轉，指令就會直接失敗，而不是悄悄產生一個這份歷史紀錄裡不該有的合併泡泡。**一個被當成暫存區使用、再用 `--ff-only` 折回去的分支，不會留下任何痕跡**，也就是說，一旦發現歷史紀錄並不想要分支，開分支的成本就是零。

接著推送，形狀就此固定下來。

## 總結

五個步驟，依照必須發生的順序：

1. **讀 `git log --oneline`，**把它對每個工作單位的提交數、訊息格式、票號、合併提交說了什麼寫下來。
2. **提出一個形狀，**用一句話說出每個提交是做什麼的，藉此檢查它。
3. **找出限制：**哪些被忽略了，哪些不是你改的。兩者都是在提案之後才出現，不是之前。
4. **用 `git reset --soft <base>` 重切，**直到符合歷史紀錄為止。因為不會動到檔案，重複幾次成本都很低。
5. **落地，**如果歷史紀錄裡沒有合併提交，就用 `git merge --ff-only` 把工作分支折回去。

這五個步驟背後的發現是，**沒有一種通用的提交形狀可以學。**規格書是存在的，只是每個儲存庫都不一樣，而且就攤在 `git log` 的第一畫面上。

既然這次工作階段有代理人協助，這裡補充一點人類判斷落在哪裡。代理人調查了歷史紀錄、提出三方分組、找出 `.gitignore` 的限制、寫了提交訊息。真正改變結果的決定是我做的：把三個提交合併成一個，以及選擇落在主分支上而不是保留功能分支。**代理人讀出了慣例；要不要照著做，不是它能決定的事。**

## 參考連結

- [git-reset 的文件，包含一張表，說明 HEAD、索引、工作目錄各自被哪一種模式動到](https://git-scm.com/docs/git-reset)
- [git-switch 的文件，這個指令在 2.23 從 git checkout 接手了切換分支的工作](https://git-scm.com/docs/git-switch)
- [git-merge 的文件，涵蓋拒絕建立合併提交的 --ff-only 模式](https://git-scm.com/docs/git-merge)
- [Git 2.23 的發布說明，引入 git switch 與 git restore 作為實驗性替代方案](https://github.blog/open-source/git/highlights-from-git-2-23/)
