When one file contains both a target edit and unfinished work, an interactive hunk may not provide a clean boundary. The object to control is not the working-tree file but the blob recorded for that path in Git's index. This article turns that idea into a reproducible sandbox exercise: derive a candidate from the committed baseline, write exact bytes as a blob, point the index entry at that object, and prove that the staged snapshot is correct without changing the working tree.

This is not a version-control governance article, and it does not address semantic merge or refactoring conflicts. Its intent is a low-level, testable explanation of git hash-object, git update-index --cacheinfo, stage-zero entries, filters, file modes, and two-sided diff verification for maintainers who need byte-exact staging.

Core answer: the index is the candidate snapshot

Reliable partial staging means constructing the complete file content intended for the next commit as a separate blob, then changing only the index entry for that path. The working tree retains every edit, so the commit candidate and the developer's unfinished state can be inspected independently.

Staging is often described as placing changes in a temporary area. That shorthand can suggest that the index stores a patch. For an ordinary tracked file, however, the index records information including a mode, an object ID, a path, and a conflict stage. The object ID identifies a complete blob. A future tree is assembled from index entries; Git does not simply scan the current working tree when a commit is created. The precise question is therefore not “which lines did I add?” but “which exact bytes should this path reference in the index?”

This model explains why three views are required. Plain git diff compares the working tree with the index and shows edits that remain unstaged. git diff --cached compares the index with HEAD and shows the proposed commit. git show :path reads the stage-zero object currently recorded for the path. Each view answers a different question. A clean-looking cached diff alone cannot prove that unrelated working-tree bytes survived.

Use porcelain first when git add -p can separate ordinary hunks clearly. Interactive staging is easier for another maintainer to understand and carries fewer assumptions about modes and filters. Plumbing becomes appropriate when two intentions occupy one inseparable hunk, when line endings or a byte-order mark must be preserved exactly, or when a deterministic fixture must supply the candidate content. Lower-level commands are precision instruments, not a default shortcut.

A useful mental invariant is: HEAD is the committed baseline, the index is the proposed next snapshot, and the working tree is the editable state. Partial staging deliberately allows all three to differ. Verification succeeds only when the staged side contains the target edit, the working side still contains the unrelated edit, and the operation itself has not rewritten the file.

Boundaries and risks before creating a candidate blob

Fix three boundaries before running plumbing commands: use the current HEAD as the baseline, update only a normal stage-zero entry, and perform the exercise in an isolated repository. Unmerged entries, special modes, attributes, or content filters require additional analysis and make a copied command unsafe.

The first boundary is file mode. A regular non-executable file commonly uses 100644, an executable file may use 100755, and a symbolic link uses another mode. Never select the value from habit. Read the existing mode with git ls-files --stage -- path or inspect the committed tree with git ls-tree HEAD -- path. The laboratory in this article intentionally uses 100644 because its fixture is a normal text file; that value is not a universal answer.

The second boundary is content conversion. git hash-object --no-filters creates an object from the bytes supplied and bypasses clean filters and end-of-line conversion selected through attributes. That is correct when the candidate file already contains the canonical bytes intended for the index. If the candidate is still in working-tree form, --path may be needed so Git can choose filters for the destination path. These approaches represent different contracts. Mixing them without examining .gitattributes can produce a blob that differs from both the intended snapshot and the visible file.

The third boundary is concurrency. The index is shared repository state. If another Git process modifies it between inspection and update, earlier observations are stale. Check git status --short and git ls-files --stage before and after the operation. Do not treat this sequence as a lock-free transaction in a shared worktree. The executed verification described here created a fresh temporary repository and did not use an existing project or anyone's in-progress edits.

Plan recovery before mutation. If the candidate is wrong, git restore --staged -- path can restore that path's index entry from HEAD while retaining the working file. Avoid commands that overwrite the entire working tree to correct a single staging mistake. Also avoid committing before reading the staged object back; a successful command exit proves only that Git accepted the command, not that the chosen content was correct.

Finally, decide how exactness will be measured. Human-readable diffs communicate intent, while byte comparison establishes representation. For CRLF, a byte-order mark, binary-adjacent text, or a missing final newline, visual review is necessary but insufficient. Keep a candidate file, a staged readback file, and before-and-after working-tree hashes so the claim can be reproduced rather than inferred.

Reproduction steps: write a blob and update the index

The workflow has four phases: establish a baseline, build a candidate, register its object, and read it back. Every phase produces an observable artifact, and all commands belong in a disposable sandbox with generic data rather than any operational repository.

  1. Create a new repository, add a baseline file with CRLF line endings and no final newline, and make one baseline commit. Then prepare a working-tree version containing both TARGET and an unrelated UNRELATED line.
  2. Build a candidate from the baseline bytes that adds only TARGET, preserving CRLF and the missing final newline. Record the working-tree hash before changing the index.
  3. Run hash-object -w --no-filters for the candidate, then register the resulting object ID for sample.txt with update-index --cacheinfo and the verified mode.
  4. Read the stage-zero object with git show :sample.txt, compare it with the candidate, compare the live file with the expected working bytes, and inspect both cached and uncached diffs.
git init sandbox-index-lab
cd sandbox-index-lab
git config user.name "Index Snapshot Lab"
git config user.email "index-snapshot@example.invalid"
: "After committing baseline sample.txt, create snapshot_candidate.bin."
before=$(git hash-object --no-filters sample.txt)
blob=$(git hash-object -w --no-filters snapshot_candidate.bin)
git update-index --cacheinfo "100644,$blob,sample.txt"
after=$(git hash-object --no-filters sample.txt)
git show :sample.txt > staged_actual.bin
cmp snapshot_candidate.bin staged_actual.bin
cmp working_expected.bin sample.txt
test "$before" = "$after"
git diff --cached --no-color -- sample.txt
git diff --no-color -- sample.txt

The -w option matters. hash-object can merely calculate an object ID, but -w also writes the object into Git's object database. The entry installed by update-index --cacheinfo must identify an object available to later tree and commit operations. A plausible-looking hexadecimal value is not a substitute for an object. The single-argument mode,object,path form also keeps the three fields together at the shell boundary.

Generate candidate bytes with a readable script instead of assembling them ad hoc on a command line. Binary writes make CRLF, a byte-order mark, and the last byte explicit. They also make the fixture repeatable. A text-mode editor can normalize line endings and turn a one-line intention into a whole-file diff. That failure may be visually noisy, but the deeper problem is that every normalized byte contributes to a different object ID.

The candidate should be complete content, not a patch fragment. update-index --cacheinfo does not apply a textual diff; it replaces the object reference for one index path. If the candidate omits unchanged lines, those lines will be absent from the staged file. This is why deriving from git cat-file blob HEAD:path or another explicit baseline is safer than manually retyping the surrounding file.

Quoting deserves attention. A path may contain spaces, and shell parsing must not split the cacheinfo tuple unexpectedly. Scripts should use variables with quotes and should reject unsupported path forms rather than silently targeting another entry. The sample deliberately uses a simple path so the exercise tests index semantics, not an unrelated quoting puzzle.

Verification: prove both staged correctness and working-tree preservation

Complete verification needs two independent facts: the object read from the index equals the candidate byte for byte, and the working-tree hash is identical before and after the index update. A cached diff containing the target line does not rule out an overwritten worktree or extra candidate changes.

The reproducible fixture was executed in a safe temporary repository with the fixed version git version 2.38.1.windows.1. The staged candidate added only TARGET, while the working tree also retained UNRELATED. The actual candidate blob was baaff5c1da309ae9ac3d54e3f1303a300e1af71a. The working-tree hash was f52f6115e1031348ce4a98b664be416030cde9f6 both before and after the index update. Byte comparisons passed, cached and uncached views each contained one hunk, and both diff checks passed. These results describe this fixture only; they are not fabricated evidence for another repository.

A disciplined review answers five questions. Does git show :sample.txt exactly match the candidate? Does git hash-object --no-filters sample.txt return the same value before and after the index operation? Does git diff --cached contain only the target intention? Does plain git diff still expose the deliberately unstaged intention? Does git ls-files --stage show the expected mode, object, stage, and path? If any answer is no, stop before committing and restore the path's index entry.

CRLF requires one nuance. A default whitespace check may treat carriage returns as trailing whitespace, even when preserving CRLF is the explicit purpose of the fixture. The verification used git -c core.whitespace=cr-at-eol diff --check and the equivalent cached command so the check recognized carriage returns at line endings. This configuration changes interpretation for that invocation; it does not replace byte comparison and does not prove that every other whitespace rule is satisfied.

For a file with a byte-order mark, compare the first bytes of the candidate and staged readback in addition to reviewing the diff. For a missing final newline, Git commonly displays a marker, but cmp or an object hash is the decisive test. Diffs explain semantic intent to reviewers. Binary comparison proves the representation. A robust verification package keeps both forms of evidence.

Record real outputs without promoting them to universal constants. Object IDs depend on exact content and, depending on repository format, the selected object hash algorithm. The IDs above are reproducible for this stated SHA-1 fixture, but another file should produce another value. Assertions should compare derived values within the run, not hard-code an expected object from an unrelated project.

Failure diagnosis and safe recovery

Typical failures come from choosing the wrong baseline, using the wrong filter contract, copying the wrong mode, or checking only the staged side. Diagnose by reading the index and the worktree first; do not hide an incorrect snapshot inside a commit and attempt to explain it later.

If the cached diff shows nearly every line changed, inspect line endings and encoding in the candidate, then review whether --no-filters was paired with working-tree-form bytes. If git show :path differs from the candidate, inspect git ls-files --stage -- path and confirm that the intended path and stage zero were updated. If the working-tree hash changes, another operation wrote the file; stop, preserve observations, and repeat in isolation instead of guessing which tool was responsible.

Restore one mistaken entry with git restore --staged -- sample.txt. After restoration, git diff --cached -- sample.txt should be empty, while git diff -- sample.txt should show all working-tree edits relative to HEAD. That outcome demonstrates again that the restoration targeted the index rather than discarding the working file. If the repository has unresolved conflicts, stages one through three have special meanings; the stage-zero laboratory described here is not the correct conflict-resolution procedure.

Do not confuse direct index updates with assume-unchanged or skip-worktree. Those flags influence working-tree checks or sparse-checkout behavior; they do not create a precise commit candidate. Hiding a change is not the same as isolating it. An auditable snapshot must be readable from the index, comparable with an explicit candidate, visible in the correct diff, and reversible without destroying edits.

An absent object is another diagnostic category. If an entry refers to an object not present in the database, later commands cannot materialize the staged content. Using hash-object -w avoids that condition for the new candidate. The repository can verify object readability with git cat-file -e "$blob^{blob}" before the index update or immediately afterward. This checks existence and type, while the staged readback still checks path association and content.

Before any commit, repeat git status --short, cached diff, uncached diff, and the index listing. Ensure that no unrelated path entered the index. If the target edit affects a build or behavior, test the tree represented by the intended snapshot. This article's executed sandbox verifies Git object and index mechanics only; it does not claim that an application compiled, passed domain tests, or was deployed.

Decision checklist and appropriate use

Choose index plumbing only when interactive staging cannot express the required boundary and reviewers can inspect the candidate bytes. Keep git add -p as the normal tool; reserve direct object and index work for encoding, line-ending, automation, or same-hunk isolation cases.

  • Work in a disposable repository or independent worktree and record the exact Git version.
  • Derive complete candidate content from an explicit baseline without overwriting the live working file.
  • Read the existing mode from the index or tree instead of assuming 100644.
  • Examine .gitattributes and choose --no-filters or --path according to the byte contract.
  • Write the object with hash-object -w and confirm that Git can read it as a blob.
  • Read the staged value through git show :path and compare it byte for byte with the candidate.
  • Compare working-tree hashes from before and after the index update.
  • Inspect both git diff --cached and plain git diff; neither view replaces the other.
  • Restore only the target index entry if verification fails, and preserve all working edits.
  • Run build or behavioral checks appropriate to the actual target before creating a commit.

The value of this approach is not that the commands are obscure. It is that the intention becomes a verifiable data contract. The candidate blob is an exact input, the index entry is an explicit reference, cached and uncached diffs are complementary observations, and the worktree hash is evidence that editing state was preserved. With each assertion reproducible, partial staging becomes an auditable snapshot operation rather than a visual guess.