Git partial staging is usually a matter of selecting the right hunk. The harder case is selecting an exact file version while preserving unrelated edits in that same file, including its original byte layout. In that situation, the useful question is not how to temporarily rearrange the working file. It is how to construct precisely the version that should exist in the index.

This article examines that narrower problem through a disposable local experiment. It builds a candidate from the committed file, changes one chosen value, stores the resulting bytes as a Git object, and registers that object in the index. The working file keeps both its intended change and its unrelated change. The example is deliberately small enough that every important claim can be checked directly.

Choose ordinary partial staging unless exact bytes are the requirement

Use git add -p as the default for Git partial staging; construct an index snapshot directly only when the intended staged bytes need explicit control. The low-level method is a deliberate exception, not a replacement for interactive staging, and its extra checks are part of the method rather than optional cleanup.

For an ordinary text edit, selecting a hunk is simpler to understand and review. Direct index construction introduces a separate candidate file version, an object identifier, and an index entry. Those additional moving parts are worthwhile only when they solve a concrete problem, such as preserving a byte order mark, carriage-return/line-feed pairs, and an absent final newline while isolating a very small change.

The example separates two application values. The committed file contains an old mode and a blue color. The working file contains a new mode and a green color. The desired staged version contains the new mode but still has the blue color. Here, the word “mode” refers to a value inside the sample file; it must not be confused with the Git file mode stored alongside an object identifier in the index.

This is a snapshot-construction problem, not a discussion of repository governance or patch delivery. A successful outcome has three observable properties: the index contains exactly the intended candidate, the working file retains its unrelated edit, and the committed baseline remains unchanged. All executable instructions below belong to the temporary sandbox. They are not a procedure to apply to an existing repository.

Treat HEAD, the index, and the working tree as separate versions

HEAD supplies the committed baseline, the index holds the staged candidate, and the working tree holds the editable file. Git partial staging succeeds when those versions differ intentionally: the index includes the selected change, while the working tree also contains the unrelated change that must remain outside the staged snapshot.

Thinking in terms of three versions avoids a common misconception: staging does not require the working file to become identical to the intended next snapshot. In this experiment, the working file deliberately remains different. Its green color is neither lost nor included in the index. That difference is the expected result, not evidence that staging failed.

The baseline is especially important because it defines what “only the selected change” means. The script reads the committed blob and constructs the candidate from those bytes. It does not start from the working file and try to remove everything that appears unrelated. This makes the example easy to reason about: one known occurrence is replaced in one known baseline, and the complete expected result is specified independently.

Starting from HEAD also creates a strict precondition. If the index already contains staged content, replacing an entry with a candidate based on HEAD can overwrite the staged version of that path. Stop if the index is dirty; this demonstration is not a method for merging existing staging decisions. Its freshly initialized repository establishes a clean index before the working file is changed, so the precondition is guaranteed by construction.

Unchanged HEAD content and unchanged working bytes are separate checks. Verifying only one cannot establish the other. Likewise, seeing the desired line in a staged diff does not prove that unrelated staging was preserved or that the stored file has the intended byte representation. The experiment therefore reads the index blob back rather than relying only on a visual patch.

Build a candidate from bytes without rewriting the working file

Construct the candidate from the committed blob as bytes, make one explicitly bounded replacement, and compare the result with independently defined target bytes. Keeping the candidate in memory avoids rewriting the working file and makes preservation of the byte order mark, line endings, and missing final newline directly testable.

The sample begins with a UTF-8 byte order mark, uses one CRLF line separator, and ends without a newline. Those features are intentional test inputs. A text-processing path that decodes, normalizes, and re-encodes the file might preserve its visible words while changing its representation. The experiment instead uses Python byte strings and passes byte input directly to the Git subprocess.

Before replacement, the script verifies that the committed blob equals the baseline and that the old mode value occurs exactly once. That count matters. A replacement expression without a uniqueness check can silently modify the wrong occurrence or conceal an unexpected input. The replacement is also limited to one occurrence. These checks make the transformation narrow and explicit rather than a broad search-and-replace recipe.

The candidate and the working version are different variables with different responsibilities. The candidate represents the desired index contents. The saved working bytes represent what must remain untouched. The target byte string is defined separately from the replacement expression so that the equality assertion checks the outcome against a concrete expected file, not merely against another name for the same computed value.

Byte equality is the strongest check in this small example, but the more specific assertions remain useful explanations. They show that the byte order mark is present, a CRLF sequence survives, and the last byte is not a line feed. These are properties that a line-oriented diff may not communicate clearly. They are not claims that arbitrary text formats can be edited safely with the same replacement expression.

Store the candidate and register only its index entry

git hash-object -w --stdin --no-filters stores the candidate bytes as a blob, and git update-index --cacheinfo mode,oid,path makes the index entry reference that blob. These are separate operations: storing an object does not stage a path, and registering the entry does not rewrite the working file.

The Git 2.38.1 hash-object documentation supports the object-writing side of this explanation. It describes reading input from standard input, writing the object with -w, and bypassing input filtering with --no-filters. In this experiment, that combination means the supplied candidate bytes are the bytes being stored, rather than content reconstructed from the working file.

The Git 2.38.1 update-index documentation supports the registration side. Its cache information form accepts the file mode, existing object identifier, and path. The object identifier returned by the first operation connects the two steps. Keeping that relationship explicit makes the index update understandable: it changes which stored content the selected entry names.

The file mode is not universally 100644. That value is appropriate for the regular non-executable file created by this sandbox, and the script obtains the actual index mode before asserting the expected value. An executable regular file can have a different mode. Other entry types demand different reasoning; substituting the demonstrated number everywhere would confuse a narrowly verified fixture with a general implementation.

Bypassing filters is also a boundary, not a convenience switch for every repository. Exact candidate bytes and the normal representation expected by a configured filter can be different things. This example does not provide generic automation for symlinks, unresolved merge entries, custom filters, or Git LFS. Its isolated configuration and plain regular file keep those concerns outside the experiment so the index-snapshot behavior can be examined on its own.

Verification: run the complete disposable-repository experiment

Verify the method with the complete sandbox script below, which creates its own temporary repository and checks the stored blob, preserved working bytes, and both diff views. The recorded run used Git for Windows 2.38.1.windows.1 and Python 3.11.15; those exact versions are asserted before the experiment proceeds.

  1. Check the toolchain. The commands below report Git and Python versions before running the script. The pinned versions describe a historical reproduction environment, not an installation or downgrade recommendation. A different version causes an assertion to stop this exact script rather than allowing an unreported change in the tested environment.
  2. Run only the sandbox. Save the complete Python block as verify_partial_staging.py in a scratch directory and run it using the displayed command. It creates its own repository inside a temporary directory and does not open an existing repository. Its baseline commit exists only to establish the three-version comparison.
  3. Read all three checks. The first checks exact staged bytes, the second checks preservation of the working file and committed blob, and the third checks the separation visible in staged and unstaged diffs. A successful command alone is not a substitute for those assertions. Do not use python -O: it disables the assertions this experiment relies on.
  4. Keep reproduction records distinct. For a newer toolchain, adapt the version assertions in a separate local copy and record that copy's versions and actual result as a separate run. Do not describe an unexecuted adaptation as tested or attribute this historical result to a different environment.
git --version
python --version
python verify_partial_staging.py

The complete script is reproduced unchanged:

"""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')

The actual recorded execution completed with exit code 0. It reported the following output; this is a correctness result for the displayed fixture, not a benchmark or a claim about untested file types.

git version 2.38.1.windows.1
Python 3.11.15
PASS 1: index equals target bytes, including BOM and CRLF
PASS 2: working tree and HEAD remain unchanged
PASS 3: cached diff isolates mode; unstaged diff retains color
PASS: all checks completed in a disposable local repository

The script limits configuration inherited by the experiment, uses an empty template, and disables line-ending conversion for the fixture. Its byte assertions therefore have a defined context. It does not test what a larger application would do with the staged file, whether the chosen logical change is useful, or whether another repository's attributes would require a different stored representation.

Read staged and unstaged diffs as two different comparisons

git diff --cached compares HEAD with the index, while plain git diff compares the index with the working tree. Both views are necessary here: the staged view should contain the mode-value change, and the unstaged view should still contain the unrelated color-value change without repeating the already staged edit.

The Git 2.38.1 diff documentation supports these comparison boundaries. This matters more than the visual similarity of the two outputs. A diff is meaningful only relative to its two inputs. Once the index has the new mode, the working file no longer differs from it on that value, even though both differ from the committed baseline.

The script asks Git not to use external diff drivers or text conversion for its two content comparisons. It then checks that the staged output includes the new mode but not the green color, and that the unstaged output shows blue changing to green. It also verifies that the staged name list contains only the sample file. These checks corroborate the intended division of changes.

The diff assertions are deliberately paired with exact blob equality. A patch can show the right edited line while being insufficient evidence about all the file's bytes. Conversely, byte equality can establish the intended index contents without teaching the reader why an unrelated edit still appears as unstaged. Together, the checks connect the stored result to the way a person will inspect the three versions.

If a later adaptation fails, the failed assertion identifies a category to investigate rather than a reason to suppress the check. A candidate mismatch concerns construction or representation. A changed working file violates preservation. An unexpected staged path violates scope. The historical passing run does not justify ignoring any of these signals in another run.

Keep the method narrow and the acceptance criteria explicit

Accept an exact index snapshot only when its bytes match the intended target, the working file and baseline remain unchanged, and the two diff views show the expected separation. Return to ordinary partial staging when that extra byte-level control is unnecessary, and stop rather than generalize beyond the sandbox's stated assumptions.

The method is useful because it separates construction from registration. First, a complete candidate is defined and stored. Then, one index entry is made to reference it. Neither operation needs the working file to temporarily lose its other edits. That separation is the central lesson of this experiment, not an invitation to hide index changes behind a broad automatic replacement tool.

The boundaries remain important even after a successful run. A candidate based on HEAD is unsuitable for silently preserving an already modified index entry. A regular-file fixture does not validate symlink behavior. A clean single-stage index does not validate unresolved merges. Bypassing filters does not establish compatibility with filter-managed content or Git LFS. These are different problems that require their own models and verification.

For Git partial staging, the practical decision is therefore straightforward: prefer the simpler interactive workflow for ordinary edits; use deliberate byte construction only when the exact staged representation is the problem being solved. Judge the result by the index blob and both comparison boundaries, not by the absence of an error message. The sandbox demonstrates that a selected change can live in the index while unrelated working-tree edits remain exactly where they were.