Git:把後面 commit 的部分檔案變更搬到前面的 commit
問題情境
開發到一半,發現 commit log 裡「實作功能」這個 commit 不只改了功能檔案,還包含了「定義 model」階段的檔案變更。這些變更在開發節奏上應該屬於前面的 commit,但實際上被混在後面的 commit 裡。想把這部分檔案的變更從後面那個 commit 抽出來,合併到前面的 commit 裡,其他檔案保持原狀。
範例
gitGraph
commit id: "A (model 定義)"
commit id: "B (其他)"
commit id: "C (功能實作)"
commit id: "D (其他)"四個 commit 的角色:
- A(接收目標):commit C 中對
models/foo.dart的修訂應該屬於這裡 - B(中間插入):A 跟 C 之間有別的 commit,不能簡單 squash
- C(變更來源):同時改了
models/foo.dart和其他 6 個檔案 - 限制:只想搬走
models/foo.dart,其他檔案保持原狀
解法核心
這個方法的核心靠 3-way merge 自動跳過重複變更 — 不需要手動從 C 移除檔案變更,git 會自己偵測「這個變更已在新 base」自動處理。
具體利用兩個 git 機制:
git rebase -i搭配edit:在指定 commit 暫停,讓我們手動修改該 commit 的內容- 3-way merge 自動 dedup:當後續 commit 被 replay 時,git 比較三個版本(base / theirs / mine),發現該檔案變更已在 mine 裡,就自動跳過
為什麼是這個方法
其他可能方案的成本:
git cherry-pick後手動解衝突:cherry-pick 會把整個 commit(包含所有檔案)複製到 A 後面,然後再手動移除不需要的變更。多出一個 commit,且流程較長- 用
git format-patch提取單一檔案:format-patch 提取的是整個 commit 的 patch,無法只選擇某個檔案。需要手動編輯 patch 檔,失敗風險高 - 直接
git add -p重新 commit:需要回到 A 後重新手動 commit 一遍,工作量大且容易遺漏
這個方法的優勢:自動化程度最高 — 只需在 A 和 C 時暫停,git 會在 replay C 時自動判斷哪些變更要跳過,無需手動識別衝突或編輯 patch。
步驟
1. 建立備份 tag
歷史改寫前先綁 tag,這樣如果出現預期外的結果可以快速復原。
1git tag backup-before-rebase HEAD2. 進入 interactive rebase,把 A 跟 C 都標成 edit
從 A 的父 commit 開始 rebase,並把 A 跟 C 都標為 edit:
1# 用環境變數注入「自動把 pick 改成 edit」的 sequence editor
2GIT_SEQUENCE_EDITOR='sed -i.bak \
3 -e "s/^pick \(<A短hash>\)/edit \1/" \
4 -e "s/^pick \(<C短hash>\)/edit \1/"' \
5GIT_EDITOR=true \
6git rebase -i <A短hash>^macOS 的
sed -i需要加空字串引數(-i '')或像上面用-i.bak留 backup 檔。 Linux 的sed -i不需要。 如果更放心用編輯器手動操作,可以拿掉GIT_EDITOR=true,讓 rebase 開你慣用的編輯器手動把兩行的pick改成edit。
執行後 git 會在 A 暫停。
3. 在 A:把 C 中該檔案的版本拉進來,amend
1# 把 C 那個 commit 對該檔案的最終內容 checkout 到工作區
2git checkout <C短hash> -- path/to/file.dart
3
4# 加入並 amend 到 A
5git add path/to/file.dart
6git commit --amend --no-edit
7
8# 繼續
9git rebase --continue
git checkout <commit> -- <path>會把指定 commit 的該檔案版本放進工作區。 因為 A 是 C 的祖先,C 的版本就是「A 的版本 + C 的 diff」,等於把 C 對該檔案的變更搬到 A。
4. 在 C:確認 git 自動跳過該檔案的變更
rebase 繼續後會 replay B、然後在 C 暫停(因為我們也把它標成 edit)。 此時該檔案對 C 的變更應該已被 git 自動跳過,驗證一下:
1git show HEAD --stat
2git diff HEAD~ HEAD -- path/to/file.dart第一個指令的檔案清單不應該再出現 path/to/file.dart,第二個指令應該是空輸出。
驗證無誤後,git 已自動完成跳過,直接繼續:
1git rebase --continue3-way merge 為什麼會自動跳過? Replay C 時 git 用 3-way merge:
- base(C 的原始父 commit):該檔案沒變
- theirs(C 原始版本):該檔案有 X 變更
- mine(amend 後的 A 接續而來的目前 HEAD):該檔案已經有 X 變更
mine 跟 theirs 的最終狀態一致 → git 認定變更已套用,replay 後的 C 對該檔案就是 no-op。
5. 驗證最終樹狀態跟備份一致
最關鍵的 sanity check:內容不應該變,只是 commit 邊界移動。
1git diff backup-before-rebase HEAD輸出必須是空的。非空就代表有東西被吃掉或多出來,立刻回滾:
1git reset --hard backup-before-rebase確認沒問題後刪 tag:
1git tag -d backup-before-rebase完整指令摘要
1# 0. 備份
2git tag backup-before-rebase HEAD
3
4# 1. Rebase,把 A 與 C 都標 edit
5GIT_SEQUENCE_EDITOR='sed -i.bak \
6 -e "s/^pick \(<A短hash>\)/edit \1/" \
7 -e "s/^pick \(<C短hash>\)/edit \1/"' \
8GIT_EDITOR=true \
9git rebase -i <A短hash>^
10
11# 2. 在 A:拉檔案、amend、繼續
12git checkout <C短hash> -- path/to/file.dart
13git add path/to/file.dart
14git commit --amend --no-edit
15git rebase --continue
16
17# 3. 在 C:驗證、繼續(不需要動手)
18git show HEAD --stat
19git rebase --continue
20
21# 4. 驗證樹一致
22git diff backup-before-rebase HEAD # 應為空
23
24# 5. 清理
25git tag -d backup-before-rebase衍伸:當變更區段在 A 跟 C 重疊
如果 A 跟 C 對該檔案動的是同一個區段(不是這個範例的 non-overlapping), 3-way merge 會跳出衝突,需要手動編輯。流程:
flowchart LR
A["在 A amend 完"] --> B["replay 到 C 衝突"]
B --> C["手動編輯衝突檔"]
C --> D["git add + git rebase --continue"]
style B fill:#e53e3e,color:#fff,stroke:#c53030
style C fill:#dd6b20,color:#fff,stroke:#c05621衝突解決原則:保留 A 已經帶過去的版本(也就是 C 想再套一次但其實一樣的內容), 讓 C 對該檔案的這次 replay 變成 no-op。
注意事項
- 改寫已 push 的歷史需要 force push:用
git push --force-with-lease比--force安全, 別人有新 commit 推上去時會被擋住,避免覆寫 - 沒 push 的 commit 改起來無風險:怎麼操作都只影響本地
- 改寫 main / master 是禁忌,這個技術只適用於 feature branch
- codegen 檔案:如果
.freezed.dart/.g.dart等是被 gitignore 的,重組 source commit 後本地需要重跑 build_runner。如果 codegen 也在版控,建議連同 source 一起搬,否則 source 跟 codegen 對不齊 - Sequence editor 自動腳本搞不定的話,拿掉
GIT_EDITOR=true,讓 rebase 開你慣用的編輯器手動改pick→edit,更直觀 - 驗證樹一致性是這個工作流程的安全網。每次重組完一定要
git diff backup HEAD跑一次