Skip to content

mingw: use ChangeTime for st_ctim when it is the latest time - #6539

Open
jzbakh wants to merge 3 commits into
git-for-windows:mainfrom
jzbakh:changetime-ctime-v4
Open

jzbakh wants to merge 3 commits into
git-for-windows:mainfrom
jzbakh:changetime-ctime-v4

Conversation

@jzbakh

@jzbakh jzbakh commented Nov 1, 2026

Copy link
Copy Markdown

This fixes #3707.

On Windows, only st_ctime can reveal a change that keeps a file's size and modification time, and st_ctim holds CreationTime. That does not change when a file is rewritten, nor when it is deleted and recreated shortly after (file system tunneling), so git status misses such changes.

  • The first commit switches mingw_lstat() to NtQueryFullAttributesFile(), and mingw_stat()/mingw_fstat() to GetFileInformationByHandleEx(); both also return ChangeTime. No behavior change.
  • The second commit lets every Git process see cloud file placeholders (OneDrive and others) as the reparse points they are, with RtlSetProcessPlaceholderCompatibilityMode(), as the MSYS2 runtime does in all its programs since Cygwin 3.5.2. Otherwise, Windows may hide that from lstat() but not from the FSCache (on Windows Server 2025: attributes 0x20 vs 0x420 for an in-sync placeholder).
  • The third commit uses ChangeTime for st_ctim when it is later than both CreationTime and LastWriteTime, and CreationTime otherwise, as before. Reparse points always keep CreationTime. On NTFS and ReFS, data writes set ChangeTime and LastWriteTime to the same value, so st_ctim only differs from today when a file changed without its LastWriteTime following: a restored or preserved mtime, an attribute, ACL or name change.

What I tried first

Approach Problem Runs
ChangeTime instead of CreationTime, or the latest of the three times Git for Windows 2.56.0 and libgit2 1.9.7, which use CreationTime, re-hash every file the other one recorded: 501 of 501 files changed after creation, 1500 of 1500 files rewritten by a branch switch on C:, and libgit2 at every status, as it does not write the index back. On FAT32 and exFAT, ChangeTime reads as 0, 1980-01-01 or LastWriteTime depending on the API, and the latest of the three missed a changed CreationTime. 41195710059, 41199752653
ChangeTime when it is the latest time, for all files Cloud placeholders get a new ChangeTime, but keep their CreationTime and LastWriteTime, when they are pinned, unpinned, hydrated or dehydrated. After "Free up space", git status downloaded 20 of 20 files again. With the sync client not running, it printed error: read error while indexing f0000: Invalid argument for each of them and listed them as modified. 41222821225, 41223703214, 41225292495
Same, but exempting placeholders by the attributes Git sees by default lstat() and the FSCache do not see the same attributes; without the FSCache, files were still downloaded. 41202067283

With this pull request: 0 files re-hashed in the first case, 0 downloaded and no errors in the second, the same as with Git for Windows 2.56.0. In a worktree shared with older versions or libgit2, only files whose ChangeTime is the latest time, for example after a save through a renamed temporary file, are still re-hashed, each time one tool reads stat data written by the other.

Notes for reviewers

  1. RtlDosPathNameToNtPathName_U_WithStatus() is linked directly. Microsoft describes it only in the Win32 dev notes, without a header or import library, and says to call it through LoadLibrary() and GetProcAddress(). The first commit declares it and links it from ntdll: the MinGW-w64 and Windows SDK import libraries have it, including for the win+VS builds on x64 and arm64 (checks below), and git.exe starts on every system I tested: Windows Server 2022 and 2025, Windows 11 24H2, Windows 11 on ARM64, Nano Server. I can load it at run time instead, as Git for Windows does for RtlGetLastNtStatus(). Without it, lstat() would need a handle per call to get ChangeTime, which took more than three times as long.
  2. Placeholders exposed in the whole process. RtlSetProcessPlaceholderCompatibilityMode() is documented in the WDK (Windows 10 1803 and later). It changes what the whole Git process sees, not only lstat(). I checked every place in Git that looks at reparse points: placeholders stay regular files and directories there. Programs that Git starts are not affected. Without the FSCache, lstat() now also reads the reparse tag of each placeholder, as it does for other reparse points: git status on 2000 in-sync placeholders took 118 ms instead of 79 ms (medians of 7). With the FSCache, and on regular files, no difference. Windows 10 1709 has cloud placeholders but not this function: there, they may still be re-hashed or downloaded.
  3. Reparse points keep CreationTime. For symlinks, cloud placeholders and other reparse points, a change that keeps the size and mtime still goes unnoticed, as today.
  4. Untested. VirtualBox and VMware shared folders, the NFS client, third-party SMB servers: one whose ChangeTime moves without changes would cause a re-hash on every git status there. Data Deduplication and ProjFS placeholders (VFS for Git, covered by the same setting according to the phnt headers): a filter that reported different attributes or times in directory listings and in file queries would make the FSCache and lstat() disagree; WOF compression does not (see the checks). The OneDrive client on Windows 10: I tested it on Windows 11.

Checks

What Run
NtQueryFullAttributesFile() vs GetFileAttributesExW(): same results for 30 kinds of paths (symlinks, junctions, locked and delete-pending files, UNC, \\?\, ...); timings; MinGW and MSVC; windows-2022 and windows-2025 https://github.com/jzbakh/git/actions/runs/41126371278
Write methods and metadata changes on NTFS, ReFS, FAT32, exFAT, SMB, WinFsp memfs and rclone: st_ctim is the same from all three code paths once files are closed (WinFsp: once its cache expires); ChangeTime equals LastWriteTime after data writes https://github.com/jzbakh/git/actions/runs/41199752653
Read-only attribute, ACL and extended attribute changes, WOF and NTFS compression, a save through a renamed copy, a same-size rewrite with the mtime restored: the FSCache, lstat() and stat() compute the same st_ctim, and Git finds the same stat data with and without the FSCache; windows-2022 and windows-2025 https://github.com/jzbakh/git/actions/runs/41305949363
Per file system, Git 2.56.0 vs this pull request: same-size rewrite and delete+recreate with the mtime restored, older CreationTime, attribute change, stable stat data; worktree shared with Git 2.56.0 and libgit2 1.9.7; the issue's reproduction on C: (tunneling); stat-related tests https://github.com/jzbakh/git/actions/runs/41223703214
Cloud Files placeholders, minimal provider on the real cldflt driver: files re-hashed and downloaded by git status, this pull request vs ChangeTime for all files, with and without the FSCache; cost of exposing placeholders https://github.com/jzbakh/git/actions/runs/41223703214
Same, with the provider not running (killed, or disconnected after "Free up space") https://github.com/jzbakh/git/actions/runs/41225292495
The same cases with the real OneDrive client 26.195.1006.0007 on Windows 11 24H2 (Git for Windows 2.53.0 for comparison): same results. "Free up space" moved ChangeTime but neither CreationTime nor LastWriteTime (output below) Test bundle, with a git.exe built from the same source as this branch: https://github.com/jzbakh/git/actions/runs/41268097163
What each Cloud Files operation changes, step by step; what the FSCache, lstat() and stat() of git.exe see of placeholders, by default and exposed https://github.com/jzbakh/git/actions/runs/41222821225
Default placeholder mode of a process, whatever its name or parent; not inherited by child processes; windows-2022 and windows-2025 https://github.com/jzbakh/git/actions/runs/41221836399
The new test fails without the fix https://github.com/jzbakh/git/actions/runs/41141756748
Full CI of this branch's code https://github.com/jzbakh/git/actions/runs/41294544846
The win+VS build (x64 and arm64) and test jobs, which only run in git-for-windows/git https://github.com/jzbakh/git/actions/runs/41303195189
Windows tests with GIT_TEST_FSCACHE=1 (t2081.2 and t4067.6 fail the same way on main: https://github.com/jzbakh/git/actions/runs/41144597474) https://github.com/jzbakh/git/actions/runs/41223702950
i686 and aarch64 builds with the stat-related tests; git.exe on Nano Server https://github.com/jzbakh/git/actions/runs/41223702551
Windows build and tests of the first commit alone https://github.com/jzbakh/git/actions/runs/41266153637
Windows build and tests of the first two commits (one test job was re-run: its first runner stopped responding after 35 minutes, without logs) https://github.com/jzbakh/git/actions/runs/41266153837

Output of the OneDrive test:

Git (git/git) has the same compat/mingw.c code; I can send that part upstream if you want.

I wrote this with an AI assistant (Claude Opus 5.5) and reviewed every change.
result-20261101-121552.txt

mingw_lstat() uses GetFileAttributesExW(), and mingw_stat() and
mingw_fstat() use GetFileInformationByHandle(), through
get_file_info_by_handle(). Neither returns ChangeTime, which a later
commit needs.

In mingw_lstat(), use NtQueryFullAttributesFile() instead: it returns
the same data as GetFileAttributesExW(), plus ChangeTime. The path is
converted with RtlDosPathNameToNtPathName_U_WithStatus(), and a failed
conversion is reported as ERROR_PATH_NOT_FOUND, as
GetFileAttributesExW() does. On windows-2022 and windows-2025 runners,
both return the same attributes, times, sizes and errors for 30 kinds
of paths, including symlinks, junctions, locked and delete-pending
files, UNC and \\?\ paths.

In get_file_info_by_handle(), use GetFileInformationByHandleEx()
instead, with FileBasicInfo for the times and attributes, and
FileStandardInfo for the size.

st_ctim still holds CreationTime. Both changes make the calls faster
on the same runners: lstat() of a relative path takes about 10% less
time, fstat() about two thirds less.

Assisted-by: Claude Opus 5.5
Signed-off-by: Jason Zbakh <jzbakh@gmail.com>
Cloud file placeholders (OneDrive "Files On-Demand" and others) are
reparse points, but Windows may hide that from lstat() and not from the
FSCache: on Windows Server 2025, lstat() saw an in-sync placeholder as
a regular file (attributes 0x20), and the FSCache as a reparse point
(0x420). The next commit needs both to see the same attributes.

Expose placeholders in every Git process with
RtlSetProcessPlaceholderCompatibilityMode(), as the MSYS2 runtime does
in its programs since Cygwin 3.5.2. This is a per-process setting:
programs that Git starts are not affected. Where the function does not
exist (before Windows 10 1803), nothing changes.

Without the FSCache, lstat() now also reads the reparse tag of
placeholders, as it does for other reparse points, to tell them from
symlinks: they stay regular files and directories. `git status` on 2000
in-sync placeholders took 118 ms instead of 79 ms. With the FSCache,
and for regular files, the times did not change.

Assisted-by: Claude Opus 5.5
Signed-off-by: Jason Zbakh <jzbakh@gmail.com>
When a file is rewritten with the same size and its modification time
is restored, only st_ctime can tell Git, as st_ino is always 0 on
Windows. But st_ctim holds CreationTime, which does not change when a
file is rewritten, nor when it is deleted and recreated shortly after
(file system tunneling). `git status` does not show such changes.

ChangeTime moves on such changes, but cannot simply replace
CreationTime: Git for Windows versions without this change and libgit2
keep using CreationTime, so sharing a worktree with them would re-hash
every file changed since its creation. FAT32 and exFAT also lack
ChangeTime: depending on the API, it reads as 0, 1980-01-01 or
LastWriteTime.

On NTFS and ReFS, locally and over SMB, data writes set ChangeTime and
LastWriteTime to the same value. ChangeTime gets later than both
CreationTime and LastWriteTime only when the file changed without its
LastWriteTime following: a restored or preserved modification time, an
attribute, ACL or name change. Use ChangeTime in that case, and
CreationTime otherwise, as before. In a worktree shared with older
versions or libgit2, only such files are then re-hashed, each time one
tool reads stat data written by the other. On FAT32 and exFAT,
ChangeTime is never the latest time, so nothing changes there.

Keep CreationTime for reparse points, such as cloud file placeholders
(OneDrive and others), which the previous commit lets lstat()
recognize: their ChangeTime moves when they are pinned, unpinned,
hydrated or dehydrated. Using it would make the next `git status`
re-hash them and download the dehydrated ones again or, if the sync
client is not running, list them as modified. For reparse points, a
change that keeps the size and mtime still goes unnoticed.

Do this in the FSCache, in mingw_lstat() and in mingw_stat() and
mingw_fstat(): the index and the untracked cache compare stat data from
all of them.

After upgrading, only files whose ChangeTime is already the latest time
are re-hashed, once. From then on, metadata-only changes also cause a
re-hash, as on Unix. core.trustCtime=false turns this off.

This fixes git-for-windows#3707.

Assisted-by: Claude Opus 5.5
Signed-off-by: Jason Zbakh <jzbakh@gmail.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Use ChangeTime instead of CreationTime in mingw_stat() and fscache_stat()

1 participant