同一個檔案同時改了兩件事,並不代表兩件事應該一起提交。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。 這是本次實際執行的歷史版本組合,不是安裝建議,也不建議為此降級既有工具。若使用新版,需在獨立實驗中調整版本斷言並重新記錄結果,不能沿用本次通過結論。

  1. 確認本機有可執行的 Git 與 Python,執行下列版本查詢。版本不同應停止本次固定版本重現,而不是忽略檢查;整個實驗不需要連線或遠端儲存庫。
  2. 將下方完整 Python 程式存成 verify_partial_staging.py,置於自行建立的實驗資料夾。程式會另建暫存目錄,隔離 Git 設定、空白範本與 hooks,並只在其中建立示範檔案。
  3. 執行 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 補丁與提交邊界,兩者不取代本篇的索引隔離檢查。