
用 git log 讀慣例,用 git reset --soft 重切 — 在推送前把提交整理成該有的形狀
提交形狀要先讀過儲存庫的歷史紀錄才能決定。用 git reset --soft,可以把尚未推送的提交重切到符合 git log 顯示的樣子,重切幾次都可以。
本頁目錄
引言
在一次長時間工作階段的尾聲,代理人針對我們一起完成的變更,提出了一個三個提交的計畫。但最後落地的卻是單一個提交。這個選擇做起來很容易,解釋起來卻很難,這中間的落差讓我很在意:我當下用的那條規則,自己竟然說不出來。
大家都在講的建議是「把工作拆成有邏輯的提交」。這話沒錯,但不夠用。它只告訴你一個提交應該要有意義,卻沒告訴你眼前這堆變更該拆成幾個提交,接縫要切在哪裡。兩個儲存庫就算 diff 完全相同,想要的答案也可能完全不一樣。
規則其實很簡單。**形狀來自 git log,而在推送之前讓你能改變主意的,是 git reset --soft。**這篇文章會走過五個步驟,做出一份符合所在儲存庫慣例的提交歷史。
為什麼提交形狀屬於儲存庫
diff 本身對於該怎麼切分沒有意見。有意見的是專案:它的發布流程、票務系統、審查習慣,還有有沒有人會對它跑 git bisect。
想像兩個專案。一個是函式庫,main 很活躍,貢獻者有數十人,歷史紀錄都是小而聚焦的提交。在這裡把一個變更拆成三個提交,顯然是對的做法,因為每一個都會在 pull request 裡被單獨閱讀。另一個專案以版本化的發布出貨,每一次發布都是單一個提交,訊息裡帶著票號。**在第二個專案裡整整齊齊地拆成三份,不是更好的做法,而是雜訊。**而且這麼拆會破壞發布、提交、票號三者之間的一對一對應關係,而整套流程就是靠這個對應關係在運作。
這兩個儲存庫都沒有把這件事寫成文件。但兩者都把它記錄在唯一一個不會過時的地方。
第 1 步:先讀歷史紀錄,再開始暫存任何東西
第一個要打的指令不是 git add,而是:
git log --oneline -5我當時作業的專案,輸出大致是這樣(細節已經一般化):
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 完全沒列出那些檔案:
git status --short原因一個指令就查出來了:
git check-ignore -v docs/draft-report.md output/question-set.xlsx.gitignore:17:docs/ docs/draft-report.md.gitignore:13:output/ output/question-set.xlsx這兩個目錄早在這次工作階段之前,就被刻意忽略了。**第三個提交沒有內容,而且從一開始就不可能有內容。**計畫從三個提交變成兩個,原本要放進去的產出,其實根本不在版本控制的範圍內。
還有第二個限制,比較不容易察覺。工作目錄裡有兩個修改過的檔案,是在這次工作階段開始之前,被別人先前的工作改動過的。代理人沒有動過它們,所以把它們挑出來標記出來,而不是用 git add -A 一併掃進去。你能提交的範圍,受限於你實際改動過的範圍,而一個跑了好幾個小時的工作階段,正是這條界線最容易被忘記的時候。
這兩個限制都是在形狀被提出之後才出現的。這個先後順序不是該避免的失誤,而正是下一步存在的理由。
第 4 步:用 git reset --soft 重切
到這個時候,已經有兩個提交存在,是在注意到票號是必要項目之前寫的,而歷史紀錄要的是一個提交,不是兩個。兩個都還沒推送。
在推送之前,提交都還只是草稿,而 git reset --soft 就是把它當草稿對待的工具:
git reset --soft <base-sha>讓這件事安全的正是這個旗標。--soft 會把分支指標移回指定的提交,然後停在那裡。索引會保留原本暫存的所有內容,工作目錄不受影響,**磁碟上沒有一個檔案會改變。**消失的只有提交之間的邊界。被丟棄的提交裡的內容全部都還暫存著,隨時可以照你現在想要的安排重新提交。
接下來重切只需要兩個指令:
git add -Agit commit結果是一個提交,裝著這次工作階段的成果,也帶著歷史紀錄要求的票號:
git log --oneline -39f8e7d6 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 步:落地,並決定到底要不要開分支
代理人在提交任何東西之前,先開了一個分支:
git switch -c fix/question-vocabularygit switch 本身就值得認識。它是在 Git 2.23 加入的,接手了 git checkout 裡切換分支的那一半工作。git checkout 原本身兼兩個不相關的任務:在分支之間移動,以及還原檔案。git switch -c <name> 只會建立並切換到一個分支,不會偷偷對你的檔案做任何事,因為還原檔案現在是 git restore 的工作。
會開分支,是因為代理人的預設行為是避免直接對儲存庫的主線提交。這是合理的預設值,但在這裡是錯誤的判斷,而歷史紀錄早就講過了:完全沒有合併提交,每一次發布都是直接落在主分支上。我選擇把它折回去:
git switch maingit merge --ff-only fix/question-vocabularygit branch -d fix/question-vocabulary--ff-only 在這串指令裡做的是實質的工作。它拒絕建立合併提交,所以如果分支沒辦法快轉,指令就會直接失敗,而不是悄悄產生一個這份歷史紀錄裡不該有的合併泡泡。一個被當成暫存區使用、再用 --ff-only 折回去的分支,不會留下任何痕跡,也就是說,一旦發現歷史紀錄並不想要分支,開分支的成本就是零。
接著推送,形狀就此固定下來。
總結
五個步驟,依照必須發生的順序:
- **讀
git log --oneline,**把它對每個工作單位的提交數、訊息格式、票號、合併提交說了什麼寫下來。 - **提出一個形狀,**用一句話說出每個提交是做什麼的,藉此檢查它。
- **找出限制:**哪些被忽略了,哪些不是你改的。兩者都是在提案之後才出現,不是之前。
- **用
git reset --soft <base>重切,**直到符合歷史紀錄為止。因為不會動到檔案,重複幾次成本都很低。 - **落地,**如果歷史紀錄裡沒有合併提交,就用
git merge --ff-only把工作分支折回去。
這五個步驟背後的發現是,**沒有一種通用的提交形狀可以學。**規格書是存在的,只是每個儲存庫都不一樣,而且就攤在 git log 的第一畫面上。
既然這次工作階段有代理人協助,這裡補充一點人類判斷落在哪裡。代理人調查了歷史紀錄、提出三方分組、找出 .gitignore 的限制、寫了提交訊息。真正改變結果的決定是我做的:把三個提交合併成一個,以及選擇落在主分支上而不是保留功能分支。代理人讀出了慣例;要不要照著做,不是它能決定的事。



