白色卡片上的紅色 Git 菱形標誌與深棕色字樣

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

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

本頁目錄

引言

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

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

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

五個步驟,以及其間的迴圈五個編號步驟垂直排列:閱讀歷史、決定形狀、找出限制、重新切分、落地。一條虛線箭頭從第三步回到第二步,標示發現限制時會退回去重新決定形狀。1閱讀歷史每個工作單位的提交數、訊息格式、票號、合併提交2決定形狀確認每個提交都能用一句話說明用途3找出限制哪些被忽略,以及哪些不是你改的4重新切分合併或拆分到符合歷史為止。檔案不會變動5落地把工作分支折回,然後推送發現限制就退回
第 3 步是會把你送回頭的步驟,這也是為什麼第 4 步一定要夠便宜。

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

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

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

兩種歷史,兩種提交形狀兩欄並排。左欄顯示由小而聚焦的提交組成的歷史,導向把工作拆成三個提交的結論。右欄顯示每一行都是帶有票號的版本發布,導向單一提交的結論。下方的橫幅說明差異相同,只有形狀不同。git log --oneline 顯示的內容main 活躍的函式庫小而聚焦的提交a1b2c3d fix: guard the null casee4f5a6b test: cover the retry pathb7c8d9e docs: note the new flag拆成三個以版本發布的儲存庫每個版本一個提交a1b2c3d feat: … (PROJ-142)e4f5a6b fix: … (PROJ-141)b7c8d9e fix: … (PROJ-139)合併成一個歷史要求的形狀相同的差異,不同的形狀。
兩欄的 diff 完全相同,不同的是各自落地的歷史紀錄。

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

第 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 reset --soft 只移動分支指標兩個面板。左邊在 base 提交之上堆疊三個提交,HEAD 位於最上方。一條標示 git reset --soft base 的箭頭指向右邊面板,其中三個提交消失,HEAD 位於 base,內容以已暫存的變更呈現。底部橫幅顯示兩個面板的工作目錄完全相同,並標示沒有任何檔案變動。執行前執行後提交C add the report draftB update the promptsA update the question setHEADbase已暫存的變更HEADbasegit reset--soft base工作目錄question-set.yaml, prompts/, docs/question-set.yaml, prompts/, docs/=沒有任何檔案變動
提交這一欄變了,工作目錄這一欄沒變。正是這種不對稱,讓這個操作可以放心重複做。

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

終端機視窗
git add -A
git commit

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

終端機視窗
git log --oneline -3
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 步:落地,並決定到底要不要開分支

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

終端機視窗
git switch -c fix/question-vocabulary

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

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

終端機視窗
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 的限制、寫了提交訊息。真正改變結果的決定是我做的:把三個提交合併成一個,以及選擇落在主分支上而不是保留功能分支。代理人讀出了慣例;要不要照著做,不是它能決定的事。

參考連結

分享這篇文章