#6312·git-lfs

Zero-filled working tree files on Windows ReFS/Dev Drive: checkout block-clones objects before they are flushed to disk

Author: josh-sachsCreated Jul 30, 2026Updated Aug 9, 2026
Labelsbugwindows

Describe the bug

On Windows ReFS volumes (including Dev Drives), git lfs checkout materializes working tree files by block cloning them from .git/lfs/objects (FSCTL_DUPLICATE_EXTENTS_TO_FILE). Block cloning remaps on-disk clusters only, and after the clone ReFS guarantees isolation between the two files (allocate-on-write). When checkout runs shortly after git lfs fetch, some object data is still in the OS write cache rather than on disk, so the clone captures the on-disk state of the object file, which is zeros. The result is working tree files with the correct length whose content is partially or fully zero-filled, permanently. The object store itself is fine: every object file verifies against its SHA-256 filename.

The clone path never flushes the source file and there is no configuration to disable cloning:

  • tools.CopyWithCallback (tools/iotools.go) tries CloneFile first, unconditionally, on every smudge/checkout.
  • CloneFile / callDuplicateExtentsToFile (tools/util_windows.go) issue FSCTL_DUPLICATE_EXTENTS_TO_FILE in 1 GiB chunks with no FlushFileBuffers on the source and no valid-data-length handling.

Microsoft's block cloning documentation describes the mechanism: the FSCTL remaps logical clusters as a metadata operation, and after the clone, writes to the source do not appear in the destination (https://learn.microsoft.com/en-us/windows/win32/fileio/block-cloning). So a clone taken before the source's cached data reaches disk can never be healed by the later flush.

There is a serious secondary effect: git-lfs's own post-checkout index refresh reads back the just-cloned files, sees the wrong bytes, and declines to refresh those index entries. The index keeps its clone-time stat data (pointer-sized), so git status reports every LFS-tracked file as modified from then on via the stat-size shortcut, without ever reading content. This looks like the "every LFS file is marked as modified" state described in #6087.

To Reproduce

The window is timing-dependent; it opens when a large fetch is immediately followed by checkout, so that object data is still dirty in the cache when the clones happen. Fast download sources and large working sets widen it.

  1. Format an ReFS volume (we used a Dev Drive on Windows 11).
  2. GIT_LFS_SKIP_SMUDGE=1 git clone <repo> onto that volume. Our repo is a private Unreal project: ~130,500 LFS-tracked files, ~56 GB, fetched from a LAN cache at 40 to 60 MB/s with lfs.concurrenttransfers = 32.
  3. git lfs fetch followed immediately by git lfs checkout (or just git lfs pull).
  4. Inspect the working tree files' bytes and compare with the pointers, or simply look for files whose leading bytes are all zero.

Observed across two runs on a freshly formatted Dev Drive:

  • Run 1 (git lfs pull): 23,393 of 130,567 LFS files (~10.5 GB) were correct-length but zero-filled. All sampled .git/lfs/objects files hashed to their SHA-256 filenames. The zero-filled files' write times span the checkout window.
  • Run 2 (git lfs fetch, then git lfs checkout): after checkout, git status --porcelain reported 130,048 modified files. An immediate second git lfs checkout rewrote everything (at clone speed) and status reported exactly the same 130,048 afterwards. A byte-level post-mortem hours later found ~680 permanently zero-filled files; the rest were correct on disk even though status still flagged them. Decoding .git/index showed 130,203 of 132,284 entries still carrying clone-time stat data (size ~130 bytes, i.e. the pointer), which is why status flags them all without reading content: the size shortcut treats a recorded-size mismatch as conclusive.

The second run also shows why an immediate re-checkout is not a valid repair: it block-clones from the same not-yet-flushed objects and inherits the same problem.

Expected behavior

Checkout should produce working tree files whose bytes match the LFS objects, regardless of how recently the objects were downloaded. Block cloning is a great fit for ReFS (metadata-speed checkout, shared clusters between object store and working tree); it just needs its durability precondition met. Possible fixes:

  • FlushFileBuffers on the source object file before cloning from it (cheap when already flushed; when not, it is I/O the lazy writer would spend anyway), or
  • write object files with write-through when block cloning may be used later, or
  • fall back to a regular copy when the source cannot be known to be durable.

Workaround

Flushing every file under .git/lfs/objects (open for write, FlushFileBuffers, close) between git lfs fetch and git lfs checkout removes the precondition for the corruption. We now do this in our provisioning script.

System environment

  • git-lfs 3.6.1 on Git for Windows, Windows 11 Pro (build 26200)
  • ReFS Dev Drive (trusted developer volume), NVMe-backed VHDX in our repros; nothing in the mechanism is VHD-specific, a virtual disk just makes flushes lazier and the window wider
  • The clone code path in current main is unchanged (no flush, no gate), so 3.7.x should be equally affected

Additional context

Diagnostics that led here, in case they help triage: SHA-256 verification of .git/lfs/objects against filenames (all valid), byte-level scans of the working tree for zero leading bytes with file timestamps correlated to the checkout window, and a raw decode of index v4 entries comparing recorded stat size/mtime and OIDs against on-disk content. Happy to provide more details from those scans if useful.