交接程式變更時,「檔案最後一樣」與「提交歷程保留下來」是兩個不同需求。Git diff 與 format-patch 差異不在副檔名,而在輸出描述的資料,以及接收端採用的套用方式。本文從本地開發筆記的補丁分類出發,用離線、合成資料的測試區分內容差異、暫存區與提交歷程,不連接任何遠端儲存庫。

核心結論是:只要檔案內容差異,使用 diff 與 apply;需要逐筆重播非合併提交及其作者資訊,使用 format-patch 與 am。兩條路都必須另外確認目標基底與結果。重播後內容相同,不代表提交識別碼相同;套用成功,也不代表應用程式行為正確。

如何選擇內容補丁或提交序列

需要搬移檔案變化時選擇內容補丁,需要保留每次變更的訊息與作者脈絡時選擇提交序列。先寫出接收端必須保留的資訊,再決定指令,不能只根據副檔名判斷。

來源筆記將 diff 描述為內容差異,將 format-patch 描述為帶有提交紀錄的格式,這個方向有助於建立心智模型,但需要補上限制。一般的 git diff A B 比較兩個端點的內容,不會輸出從 A 到 B 的每一筆提交訊息;而 git format-patch A..B 針對選取的非合併提交建立可供 git am 使用的訊息。空提交預設也可能被略過,不能把它說成任意歷程的完整備份。

副檔名不是格式保證。把內容差異命名為 .patch 不會自動增加作者與提交說明;使用 --stdout 時,多筆訊息還能集中到同一個 mailbox 檔案。接收端應檢查內容結構、產生指令與基底,而非建立「看到 patch 就用 am」的規則。這能避免格式誤判,卻不是安全審查的替代品。

例如文件修訂只需要交付最後的段落變化,單一內容補丁通常比較直接;若兩筆修改分別修正範例與補上說明,而且審查者需要理解順序,就應保留兩筆提交。本文只驗證一般文字檔與線性提交,不把結論擴張到合併拓樸、子模組或大型二進位資料。

diff 比較端點,不負責保存提交故事

兩個提交之間的差異只描述起點到終點的內容變化,即使中間經過多次修改,也不會自動保留每次修改的理由。閱讀補丁時必須同時記錄比較基底與目標端點。

git diff 不帶提交參數時,比較工作目錄與暫存區;git diff --cached 則比較暫存區與指定提交,未指定時通常以 HEAD 為基準。這兩種查詢回答不同問題。若修改已經暫存,單看不帶參數的 diff 可能看不到它;若只檢查 staged diff,又可能漏掉尚未暫存的變更。審查清單應把工作目錄、暫存區與目前提交分別列出。

本文實驗先建立包含 alpha 的基底,再用兩筆提交加入 beta 與 gamma。從基底到最終提交的 diff 包含最終內容差異,但沒有兩筆提交的訊息邊界。用它在基底上套用,檔案可達到相同結果,HEAD 卻仍停在基底。這不是資料遺失的異常,而是所選格式及指令的預期責任。

另一個常見誤解是把 diff 的雙點寫法當作逐筆歷程列舉。在官方文件中,git diff A..B 等同兩端點比較;提交選取指令使用的範圍概念不能直接套到 diff。若想確認真正搬移了哪些提交,應另看歷程與訊息,而不是從一份統計摘要反推作者意圖。

apply 與 am 的狀態改變不同

一般 apply 只修改工作目錄,不會新增提交;am 會依訊息建立提交並推進目前分支。驗收時要分別檢查檔案、暫存區與提交歷程,不能只以指令沒有報錯作為通過條件。

官方 apply 文件明確區分三種模式:預設套用到檔案,--index 同時處理檔案與暫存區,--cached 則只處理暫存區。加入 --check 時改成檢查可套用性,不實際套用。不要把這些模式混在一起推論;本文執行的是預設 apply 與獨立的 check,沒有聲稱涵蓋其他選項組合。

git am 會解析作者、作者日期、提交訊息與補丁內容,再於目前分支建立提交。這保留的是重播所需的資訊,不是原始物件逐位元相同的保證。提交者時間、父提交或其他提交內容改變,都可能使識別碼不同。範例刻意讓接收端的提交者日期不同,用相同檔案樹與不同提交識別碼呈現這個區別。

如果接收端只想審查而尚未決定建立提交,不應直接執行 am。先在隔離資料夾閱讀補丁、確認影響檔案,再進行可套用性檢查。不要為了讓一份不相容的補丁通過,就立刻放寬路徑或忽略空白檢查;衝突往往代表基底、行尾或修改順序需要重新確認。

驗證與重現:三個可以失敗的檢查

先在全新的離線測試資料夾建立合成提交,再分別驗證檢查不寫入、套用不提交,以及訊息重播保留邊界。每個條件都由斷言判定,任何非預期差異都應停止流程。

固定重現環境為 Windows、Git for Windows 2.38.1.windows.1 與 Python 3.11.15。這是本次實測基準,不是版本升級或安全性建議;不要為了閱讀本文而降級日常工具。其他版本可以另行驗證,但本文腳本會先拒絕不同版本,避免把跨版本結果混寫成同一次實驗。

前置條件是兩個執行檔可由命令列呼叫、本機暫存目錄可寫入,以及將下方完整程式存為 git_patch_lab.py。程式只建立新的本地儲存庫與 worktree,保留實驗資料,不刪除既有檔案;作者身分均為不可投遞的示範值,不使用任何真實帳號。它隔離既有 Git 環境變數、系統及全域設定,固定行尾處理,並使用空白 template 與 hooks 目錄。

  1. 執行 git --version、python --version,確認固定版本後執行 python git_patch_lab.py。第一項檢查執行 git apply --check,要求檔案、暫存區與 HEAD 均不變;非零結束碼或任何狀態改變都是失敗。
  2. 同一次執行接著呼叫一般 git apply,要求檔案恰為 alpha、beta、gamma 三行、HEAD 不變且 staged diff 為空。只看到文字增加但提交位置也改變,不能算通過。
  3. 程式在另一個全新 worktree 執行 git am,要求新增兩筆提交、順序與訊息正確、作者及作者日期保留,最終檔案樹相等但提交識別碼不同。每個斷言都通過才接受整個實驗。
"""Offline, synthetic Git patch semantics verification. No existing repositories touched."""
import os
from pathlib import Path
import subprocess
import tempfile
import sys

assert sys.version_info[:3] == (3, 11, 15), 'Use Python 3.11.15'
assert subprocess.check_output(['git', '--version'], text=True).strip() == 'git version 2.38.1.windows.1', 'Use Git for Windows 2.38.1.windows.1'
root = Path(tempfile.mkdtemp(prefix='patch-semantics-'))
(root / 'empty.config').touch()
(root / 'hooks').mkdir()
(root / 'template').mkdir()
env = {k: v for k, v in os.environ.items() if not k.startswith('GIT_')}
env.update(GIT_CONFIG_NOSYSTEM='1', GIT_CONFIG_GLOBAL=str(root / 'empty.config'), GIT_TERMINAL_PROMPT='0', GIT_AUTHOR_NAME='Example Author', GIT_AUTHOR_EMAIL='author@example.invalid', GIT_COMMITTER_NAME='Example Integrator', GIT_COMMITTER_EMAIL='integrator@example.invalid', GIT_AUTHOR_DATE='2020-01-01T00:00:00+00:00', GIT_COMMITTER_DATE='2020-01-02T00:00:00+00:00')
repo = root / 'repo'
repo.mkdir()
def git(*args, ok=True):
    result = subprocess.run(['git', '-c', 'core.autocrlf=false', '-c', 'commit.gpgsign=false', '-c', 'core.hooksPath=' + str(root / 'hooks'), *args], cwd=repo, env=env, capture_output=True)
    if ok and result.returncode:
        raise RuntimeError(result.stderr.decode('utf-8', 'replace'))
    return result

git('init', '--template=' + str(root / 'template'))
f = repo / 'sample.txt'
f.write_bytes(b'alpha\n')
git('add', 'sample.txt')
git('commit', '-m', 'Base')
base = git('rev-parse', 'HEAD').stdout.strip().decode()
f.write_bytes(b'alpha\nbeta\n')
git('add', 'sample.txt')
git('commit', '-m', 'Add beta')
f.write_bytes(b'alpha\nbeta\ngamma\n')
git('add', 'sample.txt')
git('commit', '-m', 'Add gamma')
tip = git('rev-parse', 'HEAD').stdout.strip().decode()
diff = root / 'change.diff'
mbox = root / 'series.mbox'
diff.write_bytes(git('diff', base, tip, '--', 'sample.txt').stdout)
mbox.write_bytes(git('format-patch', '--stdout', '--no-signature', base + '..' + tip).stdout)
git('checkout', '-b', 'apply-check', base)
before = f.read_bytes()
git('apply', '--check', str(diff))
assert f.read_bytes() == before
assert git('status', '--porcelain').stdout == b''
assert git('rev-parse', 'HEAD').stdout.strip().decode() == base
print('PASS check: working tree, index, and HEAD unchanged')
git('apply', str(diff))
assert f.read_bytes() == b'alpha\nbeta\ngamma\n'
assert git('rev-parse', 'HEAD').stdout.strip().decode() == base
assert git('diff', '--cached').stdout == b''
print('PASS apply: target content, unchanged HEAD, empty staged diff')
am_repo = root / 'am-repo'
git('worktree', 'add', '-b', 'mail-check', str(am_repo), base)
repo = am_repo
env['GIT_COMMITTER_DATE'] = '2020-01-03T00:00:00+00:00'
git('am', str(mbox))
assert git('rev-list', '--count', base + '..HEAD').stdout.strip() == b'2'
assert git('log', '--reverse', '--format=%s', base + '..HEAD').stdout.splitlines() == [b'Add beta', b'Add gamma']
assert git('log', '--format=%an', base + '..HEAD').stdout.splitlines() == [b'Example Author', b'Example Author']
assert git('log', '--format=%aI', base + '..HEAD').stdout.splitlines() == [b'2020-01-01T00:00:00+00:00'] * 2
assert git('diff', tip, 'HEAD').stdout == b''
assert git('rev-parse', 'HEAD').stdout.strip().decode() != tip
print('PASS am: two commits, subjects/authors/dates retained, equal tree, different commit ID')
print('PASS all: offline synthetic repositories only')

本次排程已實際執行上述完整程式,結束碼為零。check 的狀態不變檢查、apply 的內容與 HEAD 檢查,以及 am 的兩筆提交、作者、日期、檔案樹與識別碼檢查全部通過。這是真實的本機語意驗證,不是效能測試,沒有延遲、吞吐量或成功率的推估。

相容性與失敗時的判讀順序

補丁能否套用取決於接收端內容、路徑與上下文,不只取決於工具版本。先確認基底與檔案範圍,再檢查行尾與格式,最後才討論特殊選項,避免把錯誤條件掩蓋成成功。

本文沒有測試二進位內容、重新命名偵測、子模組或合併提交,因此這些情境不能沿用三行文字實驗的通過結論。官方 format-patch 文件說明合併提交不會以一般補丁完整重建;需要保存分支拓樸時,應重新定義交付方式,而不是增加幾個參數就聲稱等價。這是功能邊界,不是工具故障。

對三方合併也應保留條件:官方 apply 與 am 文件都說明,補丁需要帶有原始 blob 身分資訊,而且對應物件必須在本機可用,才能嘗試相關回退。本文不執行三方合併,不承諾它必然解決衝突。若發生錯誤,先保留測試目錄與錯誤訊息,重新取得正確基底,在另一個新目錄重現,不對既有工作進行強制修復。

對接收內容的安全判斷也要獨立存在。可套用性只是格式與上下文的判斷,不代表程式碼值得執行。這份實驗完全不下載第三方補丁,也不執行補丁帶來的程式;只有自建的文字內容與已審閱測試程式。若日後擴充案例,仍應保持這個界線。

審查清單與來源界線

交付前要確認格式、基底、預期檔案結果與提交邊界,並保留可重現證據。本文的官方文件查核支持指令語意,實驗只支持合成案例,不延伸成專案成果或效能保證。

  • 先寫明接收端要的是最後內容,還是逐筆提交與說明。
  • 保存產生補丁的完整指令、比較端點及測試版本。
  • 在全新測試目錄檢查工作目錄、暫存區與 HEAD,不只看畫面差異。
  • 區分相同檔案樹與相同提交識別碼,不用其中之一代替另一項證據。
  • 對未測情境明確標示界線,遇到衝突停止並調查。

本文與既有版本控制治理文章不同:前者解決具體補丁交換的語意及測試問題,後者討論變更責任與可追溯管理。這裡不重寫團隊流程,也不把個人姓名搜尋流量導向不相關的指令教學。結構化資料只建議 BlogPosting,不加沒有可見問答內容的 FAQPage,也不宣稱搜尋引擎一定提供特殊呈現。

來源筆記提供內容補丁與提交補丁的原始分類;官方固定版本文件補上端點比較、預設 apply 行為、am 重播與非合併提交限制。文章中的實驗是為了驗證這些概念而建立的合成範例,不是 Jerry 的專案紀錄或工作成果。