同一個檔案同時改了兩件事,並不代表兩件事應該一起提交。Git 精準部分暫存的目標,是建立只包含目標改動的索引快照,而不是先把其他改動從工作區移走。 本文用可重現的小型實驗,說明 hash-object、update-index 與兩種差異檢查如何共同證明修改邊界。
本文聚焦如何只暫存同檔的一部分修改,不討論補丁傳送或版本控制制度。內容以 Git 技巧筆記的部分暫存概念為起點,再用 Git 官方文件查核指令語意;不沿用筆記中的個別環境、作業事件或成效數字。
先分清楚 HEAD、索引與工作區
精準暫存需要同時比較已提交版本、索引快照與工作區內容,不能只看編輯器畫面。索引可以只含目標修改,工作區則保留完整改動;兩者不同是刻意設計,不一定代表操作失敗。
把三個版本想成三份不同用途的文件:HEAD 是目前提交所指向的基準,索引是準備交付的內容,工作區則是仍在編輯的完整版本。對已存在且沒有合併衝突的一般檔案,這個模型能清楚回答「改了什麼」與「準備提交什麼」。未建立初始提交的儲存庫不在本實驗範圍內。
例如基準檔包含 mode=old 與 color=blue。目前工作區已改成 mode=new 與 color=green,但這次只想交付模式變更。正確索引應是 mode=new 加上 color=blue。若索引出現綠色,表示不相干改動被帶入;若工作區回到藍色,則表示仍在進行的修改被動到了。
這個例子的重要性不在字串本身,而在建立了三份可以逐位元組比較的預期內容。只憑「指令沒有報錯」不足以證明暫存正確;還要明確指出哪個版本允許改變,哪個版本必須保持不變。
何時需要直接建立索引快照
一般文字修改優先使用互動式部分暫存;只有需要精確控制候選內容、相鄰變更難以拆分,或必須驗證位元組不變時,才考慮直接建立物件。低階方法增加控制力,也把更多檢查責任交給操作者。
git add -p 通常是較容易理解的入口,不必為每次小修都建立額外腳本。本篇展示的是例外流程:從可信的基準內容產生候選版本,再把候選版本登記到索引。它不是把畫面上選到的幾行直接當成提交,而是重新定義該路徑在索引中的完整檔案內容。
這也帶來第一個停止條件:如果同一路徑原本已有其他暫存內容,直接從 HEAD 建立候選版本,可能把原先暫存的修改排除掉。工作區雖然未變,索引中的交付選擇卻可能被覆寫。因此本實驗先建立乾淨基準,再進行單一暫存操作;不能把它當成可盲目套用到任何現有儲存庫的工具。
第二個停止條件是候選替換不夠明確。範例要求 mode=old 恰好出現一次,並在產生內容前檢查。若找不到或出現多次,應直接失敗,重新理解資料結構,而不是擴大替換範圍。對實際程式而言,精準還包含語意:即使只差一行,也可能依賴其他尚未交付的修改。
物件寫入與索引登記是兩個動作
hash-object -w 負責把候選內容寫入物件資料庫,update-index --cacheinfo 則登記路徑、模式與物件識別碼。兩者必須配合使用;建立物件本身不等於完成暫存,登記索引也不是提交。
Git 官方 hash-object 文件指出,未指定物件類型時預設為 blob;-w 才會實際寫入物件。範例把候選位元組送入標準輸入,並明示 --no-filters,讓示範中的輸入不經過行尾轉換等輸入篩選。取得的物件識別碼由 Git 回傳,不自行推算,也不假設它一定具有某個固定長度。
接著,update-index --cacheinfo 接受模式、物件與路徑資訊。本例先讀取索引模式,確認它是一般非可執行檔的 100644,才套用到 sample.txt。不能把所有路徑都硬設成這個值;可執行檔、符號連結與子模組的處理條件不同,不屬於本篇已測試範圍。
本例不改寫工作區的檔案,也不把候選變更建立成新的交付提交。唯一的基準提交是在全新暫存儲存庫內建立,使用示範識別資訊,沒有設定遠端。流程完成後,索引是候選內容,工作區仍是包含兩項修改的版本,基準提交也維持原樣。
位元組保留不等於適用所有檔案
以位元組產生候選內容,可以避免本例的文字編解碼及換行轉換,但不能取代對儲存庫屬性的理解。若檔案依賴篩選器、特殊模式或合併階段,應停止套用此範例,另建對應的隔離測試。
測試資料刻意帶有 UTF-8 BOM、CRLF 行尾,而且檔尾沒有換行。這不是要求所有專案採用同一格式,而是加入能揭露意外轉換的條件。如果透過文字模式讀寫後多加了最後一個換行,即使編輯器看起來相同,候選位元組比較仍會失敗。
--no-filters 的作用是保留提供給物件寫入指令的內容,不是替每個專案保證正確正規化。某些儲存庫會透過屬性規則規範索引內容;繞過原本需要的轉換反而可能違反該規範。本文的實驗停用自動行尾轉換、隔離全域屬性設定,且沒有加入專案屬性檔,因此只能證明這組明確條件下的行為。
同樣地,本實驗沒有測試大型二進位檔、LFS、稀疏工作區、未解決合併或同步操作。若另一個程式在檢查後改動索引,前面的觀察就不能代表寫入時狀態。更廣泛的工具需要另外設計競爭處理與錯誤復原,不能從這個單一路徑實驗推論已全面支援。
本機驗證:重現部分暫存的三項不變條件
驗證應在全新且可丟棄的本機儲存庫內,同時檢查候選索引、工作區不變與差異方向。以下腳本自行建立隔離環境,不接觸現有專案;任一版本或內容斷言失敗,都不能視為驗證通過。
固定重現環境:Windows、Git for Windows 2.38.1.windows.1、Python 3.11.15。 這是本次實際執行的歷史版本組合,不是安裝建議,也不建議為此降級既有工具。若使用新版,需在獨立實驗中調整版本斷言並重新記錄結果,不能沿用本次通過結論。
- 確認本機有可執行的 Git 與 Python,執行下列版本查詢。版本不同應停止本次固定版本重現,而不是忽略檢查;整個實驗不需要連線或遠端儲存庫。
- 將下方完整 Python 程式存成
verify_partial_staging.py,置於自行建立的實驗資料夾。程式會另建暫存目錄,隔離 Git 設定、空白範本與 hooks,並只在其中建立示範檔案。 - 執行
python verify_partial_staging.py。通過條件是三組PASS都出現且程序退出碼為零;版本不符、指令錯誤、任一斷言失敗或缺少檢查結果都算失敗,不可只擷取最後一行判定。請勿使用python -O,因為它會停用本實驗所依賴的斷言。
git --version
python --version
python verify_partial_staging.py
"""Offline disposable-repository experiment; never opens an existing repo."""
import os
from pathlib import Path
import subprocess
import sys
import tempfile
assert sys.version_info[:3] == (3, 11, 15), 'Requires Python 3.11.15'
version = subprocess.check_output(['git', '--version'], text=True).strip()
assert version == 'git version 2.38.1.windows.1', version
print(version)
print('Python ' + sys.version.split()[0])
env = {k: v for k, v in os.environ.items() if not k.startswith('GIT_')}
env.update(GIT_CONFIG_NOSYSTEM='1', GIT_CONFIG_GLOBAL=os.devnull,
GIT_TERMINAL_PROMPT='0')
with tempfile.TemporaryDirectory(prefix='index-lab-') as temp:
root = Path(temp)
repo = root / 'repo'
repo.mkdir()
template = root / 'empty-template'
template.mkdir()
def git(*args, data=None):
return subprocess.run(['git', *args], cwd=repo, env=env,
input=data, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, check=True).stdout
git('init', '--quiet', '--template=' + str(template))
git('config', 'core.autocrlf', 'false')
git('config', 'core.attributesFile', os.devnull)
git('config', 'core.hooksPath', str(template))
git('config', 'commit.gpgsign', 'false')
base = b'\xef\xbb\xbfmode=old\r\ncolor=blue'
target = b'\xef\xbb\xbfmode=new\r\ncolor=blue'
wip = b'\xef\xbb\xbfmode=new\r\ncolor=green'
file = repo / 'sample.txt'
file.write_bytes(base)
git('add', '--', 'sample.txt')
git('-c', 'user.name=Sandbox Example', '-c',
'user.email=demo@example.invalid', 'commit', '--quiet', '-m', 'baseline')
file.write_bytes(wip)
before = file.read_bytes()
head = git('cat-file', 'blob', 'HEAD:sample.txt')
assert head == base
assert head.count(b'mode=old') == 1
candidate = head.replace(b'mode=old', b'mode=new', 1)
mode = git('ls-files', '--stage', '--', 'sample.txt').split()[0].decode()
assert mode == '100644'
oid = git('hash-object', '-w', '--stdin', '--no-filters', data=candidate).decode().strip()
git('update-index', '--cacheinfo', f'{mode},{oid},sample.txt')
staged = git('cat-file', 'blob', ':sample.txt')
assert staged == target
assert staged.startswith(b'\xef\xbb\xbf') and b'\r\n' in staged
assert not staged.endswith(b'\n')
print('PASS 1: index equals target bytes, including BOM and CRLF')
assert file.read_bytes() == before == wip
assert git('cat-file', 'blob', 'HEAD:sample.txt') == base
print('PASS 2: working tree and HEAD remain unchanged')
cached = git('diff', '--cached', '--no-ext-diff', '--no-textconv', '--', 'sample.txt')
unstaged = git('diff', '--no-ext-diff', '--no-textconv', '--', 'sample.txt')
assert b'mode=new' in cached and b'color=green' not in cached
assert b'-color=blue' in unstaged and b'+color=green' in unstaged
changed = git('diff', '--cached', '--name-only').decode().splitlines()
assert changed == ['sample.txt']
print('PASS 3: cached diff isolates mode; unstaged diff retains color')
print('PASS: all checks completed in a disposable local repository')
本次實際結果: 在上述固定環境執行成功,退出碼為零。第一項確認索引等於目標位元組且保留 BOM、CRLF 與檔尾無換行;第二項確認工作區與 HEAD 都未變;第三項確認暫存差異只有模式修改,而未暫存差異仍含顏色修改。這是正確性測試,沒有量測耗時、吞吐量或效能改善。
用兩種 diff 檢查交付邊界
暫存完成後,應分別檢查索引相對基準的差異,以及工作區相對索引的差異。前者回答準備交付哪些內容,後者回答還留下哪些修改;只有兩個方向同時符合預期,才足以支持隔離成功。
依 Git 官方 diff 文件,git diff --cached 在未另指定提交時通常以 HEAD 為比較基準;一般 git diff 則呈現工作區相對索引的變更。本例也使用 --no-ext-diff 與 --no-textconv 避免外部差異呈現機制干擾驗證,最後另查暫存路徑清單,要求只包含示範檔。
不要把未暫存差異仍然存在視為失敗。 本次本來就要保留綠色修改,因此第二份差異應持續顯示藍色變綠色。相反地,如果兩份差異都含顏色修改,或第二份差異突然消失,才需要回到三個版本逐一核對。
索引隔離正確仍不等於程式可以獨立執行。工作區的完整版本可能通過測試,但目標快照缺少另一項必要改動。若把這個概念延伸到程式交付,應在另一個隔離測試專案中檢查準備交付的完整快照;本篇只有文字資料實驗,未宣稱任何應用程式建置或功能已通過。
查核依據與使用前檢查清單
本篇結論由本機實驗與固定版本的 Git 官方文件共同支持,範圍限於一般檔案的索引內容隔離。採用前應確認既有暫存、檔案模式、屬性規則及測試範圍,避免把小型實驗擴張成通用保證。
官方來源均已實際開啟並核對內容,中英文版本共用以下文件與事實:
- Git v2.38.1:update-index:核對
--cacheinfo的模式、物件與路徑參數,以及索引登記語意。 - Git v2.38.1:hash-object:核對
-w寫入物件、標準輸入與--no-filters行為。 - Git v2.38.1:diff:核對
--cached與一般差異比較的兩組端點。
檢查清單應回答:是否先定義三份預期內容?是否確認候選替換唯一?是否確認沒有既有暫存選擇需要保留?是否保留正確模式?是否排除未測試的特殊檔案?是否比較完整位元組,而不只觀察差異畫面?是否把版本不符明確當成另一輪驗證?
結論:部分暫存的可靠性來自可觀察的邊界,不是低階指令本身。 先選擇最簡單、可審查的方法;真的需要建立索引快照時,再以明確基準、候選內容與雙向差異證明它只做了預定修改。版本控制制度可另讀版本控制治理;若問題是跨環境交換修改,則應閱讀Git 補丁與提交邊界,兩者不取代本篇的索引隔離檢查。