From 5ae594f30be8a89a9e345e4e5e454f4b92156da1 Mon Sep 17 00:00:00 2001 From: Sam Bostock Date: Tue, 9 Dec 2025 06:23:39 +0000 Subject: [PATCH 01/30] doc: fix `update-ref` `symref-create` formatting `symref-create` should be followed `::`, not `:`. The lack of second colon (`:`) causes it to appear as regular text (`

`) instead of as a description list term (`

`) in the HTML documentation. Signed-off-by: Sam Bostock Signed-off-by: Junio C Hamano --- Documentation/git-update-ref.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Documentation/git-update-ref.txt b/Documentation/git-update-ref.txt index 9e6935d38d031b..184380f994b285 100644 --- a/Documentation/git-update-ref.txt +++ b/Documentation/git-update-ref.txt @@ -109,7 +109,7 @@ verify:: Verify against but do not change it. If is zero or missing, the ref must not exist. -symref-create: +symref-create:: Create symbolic ref with after verifying that it does not exist. From 3b96b99683679ee0d0c4e05cb5d1da37e13e02e5 Mon Sep 17 00:00:00 2001 From: Karsten Blees Date: Fri, 9 Jan 2026 20:04:58 +0000 Subject: [PATCH 02/30] mingw: don't call `GetFileAttributes()` twice in `mingw_lstat()` The Win32 API function `GetFileAttributes()` cannot handle paths with trailing dir separators. The current `mingw_stat()`/`mingw_lstat()` implementation calls `GetFileAttributes()` twice if the path has trailing slashes (first with the original path that was passed as function parameter, and and a second time with a path copy with trailing '/' removed). With the conversion to wide Unicode, we get the length of the path for free, and also have a (wide char) buffer that can be modified. This makes it easy to avoid that extraneous Win32 API call. Signed-off-by: Karsten Blees Signed-off-by: Johannes Schindelin Signed-off-by: Junio C Hamano --- compat/mingw.c | 53 ++++++++++++++------------------------------------ 1 file changed, 15 insertions(+), 38 deletions(-) diff --git a/compat/mingw.c b/compat/mingw.c index cf4f3c92e7a889..ae6826948e718d 100644 --- a/compat/mingw.c +++ b/compat/mingw.c @@ -918,8 +918,9 @@ static int has_valid_directory_prefix(wchar_t *wfilename) } /* We keep the do_lstat code in a separate function to avoid recursion. - * When a path ends with a slash, the stat will fail with ENOENT. In - * this case, we strip the trailing slashes and stat again. + * When a path ends with a slash, the call to `GetFileAttributedExW()` + * would fail. To prevent this, we strip any trailing slashes before that + * call. * * If follow is true then act like stat() and report on the link * target. Otherwise report on the link itself. @@ -928,9 +929,18 @@ static int do_lstat(int follow, const char *file_name, struct stat *buf) { WIN32_FILE_ATTRIBUTE_DATA fdata; wchar_t wfilename[MAX_PATH]; - if (xutftowcs_path(wfilename, file_name) < 0) + int wlen = xutftowcs_path(wfilename, file_name); + if (wlen < 0) return -1; + /* strip trailing '/', or GetFileAttributes will fail */ + while (wlen && is_dir_sep(wfilename[wlen - 1])) + wfilename[--wlen] = 0; + if (!wlen) { + errno = ENOENT; + return -1; + } + if (GetFileAttributesExW(wfilename, GetFileExInfoStandard, &fdata)) { buf->st_ino = 0; buf->st_gid = 0; @@ -990,39 +1000,6 @@ static int do_lstat(int follow, const char *file_name, struct stat *buf) return -1; } -/* We provide our own lstat/fstat functions, since the provided - * lstat/fstat functions are so slow. These stat functions are - * tailored for Git's usage (read: fast), and are not meant to be - * complete. Note that Git stat()s are redirected to mingw_lstat() - * too, since Windows doesn't really handle symlinks that well. - */ -static int do_stat_internal(int follow, const char *file_name, struct stat *buf) -{ - size_t namelen; - char alt_name[PATH_MAX]; - - if (!do_lstat(follow, file_name, buf)) - return 0; - - /* if file_name ended in a '/', Windows returned ENOENT; - * try again without trailing slashes - */ - if (errno != ENOENT) - return -1; - - namelen = strlen(file_name); - if (namelen && file_name[namelen-1] != '/') - return -1; - while (namelen && file_name[namelen-1] == '/') - --namelen; - if (!namelen || namelen >= PATH_MAX) - return -1; - - memcpy(alt_name, file_name, namelen); - alt_name[namelen] = 0; - return do_lstat(follow, alt_name, buf); -} - static int get_file_info_by_handle(HANDLE hnd, struct stat *buf) { BY_HANDLE_FILE_INFORMATION fdata; @@ -1048,11 +1025,11 @@ static int get_file_info_by_handle(HANDLE hnd, struct stat *buf) int mingw_lstat(const char *file_name, struct stat *buf) { - return do_stat_internal(0, file_name, buf); + return do_lstat(0, file_name, buf); } int mingw_stat(const char *file_name, struct stat *buf) { - return do_stat_internal(1, file_name, buf); + return do_lstat(1, file_name, buf); } int mingw_fstat(int fd, struct stat *buf) From 48bc5094de4d2c549efd82780d4488071955d4ff Mon Sep 17 00:00:00 2001 From: Karsten Blees Date: Fri, 9 Jan 2026 20:04:59 +0000 Subject: [PATCH 03/30] mingw: implement `stat()` with symlink support With respect to symlinks, the current `mingw_stat()` implementation is almost identical to `mingw_lstat()`: except for the file type (`st_mode & S_IFMT`), it returns information about the link rather than the target. Implement `mingw_stat()` by opening the file handle requesting minimal permissions, and then calling `GetFileInformationByHandle()` on it. This way, all links are resolved by the Windows file system layer. Signed-off-by: Karsten Blees Signed-off-by: Johannes Schindelin Signed-off-by: Junio C Hamano --- compat/mingw.c | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/compat/mingw.c b/compat/mingw.c index ae6826948e718d..13970ae729872e 100644 --- a/compat/mingw.c +++ b/compat/mingw.c @@ -1027,9 +1027,26 @@ int mingw_lstat(const char *file_name, struct stat *buf) { return do_lstat(0, file_name, buf); } + int mingw_stat(const char *file_name, struct stat *buf) { - return do_lstat(1, file_name, buf); + wchar_t wfile_name[MAX_PATH]; + HANDLE hnd; + int result; + + /* open the file and let Windows resolve the links */ + if (xutftowcs_path(wfile_name, file_name) < 0) + return -1; + hnd = CreateFileW(wfile_name, 0, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, NULL, + OPEN_EXISTING, FILE_FLAG_BACKUP_SEMANTICS, NULL); + if (hnd == INVALID_HANDLE_VALUE) { + errno = err_win_to_posix(GetLastError()); + return -1; + } + result = get_file_info_by_handle(hnd, buf); + CloseHandle(hnd); + return result; } int mingw_fstat(int fd, struct stat *buf) From 882e5e05282758c736bba4e719c5f6f626986fe7 Mon Sep 17 00:00:00 2001 From: Karsten Blees Date: Fri, 9 Jan 2026 20:05:00 +0000 Subject: [PATCH 04/30] mingw: drop the separate `do_lstat()` function With the new `mingw_stat()` implementation, `do_lstat()` is only called from `mingw_lstat()` (with the function parameter `follow == 0`). Remove the extra function and the old `mingw_stat()`-specific (`follow == 1`) logic. Signed-off-by: Karsten Blees Signed-off-by: Johannes Schindelin Signed-off-by: Junio C Hamano --- compat/mingw.c | 23 ++--------------------- 1 file changed, 2 insertions(+), 21 deletions(-) diff --git a/compat/mingw.c b/compat/mingw.c index 13970ae729872e..ec6c2801d3cbf4 100644 --- a/compat/mingw.c +++ b/compat/mingw.c @@ -917,15 +917,7 @@ static int has_valid_directory_prefix(wchar_t *wfilename) return 1; } -/* We keep the do_lstat code in a separate function to avoid recursion. - * When a path ends with a slash, the call to `GetFileAttributedExW()` - * would fail. To prevent this, we strip any trailing slashes before that - * call. - * - * If follow is true then act like stat() and report on the link - * target. Otherwise report on the link itself. - */ -static int do_lstat(int follow, const char *file_name, struct stat *buf) +int mingw_lstat(const char *file_name, struct stat *buf) { WIN32_FILE_ATTRIBUTE_DATA fdata; wchar_t wfilename[MAX_PATH]; @@ -959,13 +951,7 @@ static int do_lstat(int follow, const char *file_name, struct stat *buf) if (handle != INVALID_HANDLE_VALUE) { if ((findbuf.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) && (findbuf.dwReserved0 == IO_REPARSE_TAG_SYMLINK)) { - if (follow) { - char buffer[MAXIMUM_REPARSE_DATA_BUFFER_SIZE]; - buf->st_size = readlink(file_name, buffer, MAXIMUM_REPARSE_DATA_BUFFER_SIZE); - } else { - buf->st_mode = S_IFLNK; - } - buf->st_mode |= S_IREAD; + buf->st_mode = S_IFLNK | S_IREAD; if (!(findbuf.dwFileAttributes & FILE_ATTRIBUTE_READONLY)) buf->st_mode |= S_IWRITE; } @@ -1023,11 +1009,6 @@ static int get_file_info_by_handle(HANDLE hnd, struct stat *buf) return 0; } -int mingw_lstat(const char *file_name, struct stat *buf) -{ - return do_lstat(0, file_name, buf); -} - int mingw_stat(const char *file_name, struct stat *buf) { wchar_t wfile_name[MAX_PATH]; From 2c37842ff97c28876e980f1829bc732c98dcde14 Mon Sep 17 00:00:00 2001 From: Karsten Blees Date: Fri, 9 Jan 2026 20:05:01 +0000 Subject: [PATCH 05/30] mingw: let `mingw_lstat()` error early upon problems with reparse points When obtaining lstat information for reparse points, we need to call `FindFirstFile()` in addition to `GetFileInformationEx()` to obtain the type of the reparse point (symlink, mount point etc.). However, currently there is no error handling whatsoever if `FindFirstFile()` fails. Call `FindFirstFile()` before modifying the `stat *buf` output parameter and error out if the call fails. Note: The `FindFirstFile()` return value includes all the data that we get from `GetFileAttributesEx()`, so we could replace `GetFileAttributesEx()` with `FindFirstFile()`. We don't do that because `GetFileAttributesEx()` is about twice as fast for single files. I.e. we only pay the extra cost of calling `FindFirstFile()` in the rare case that we encounter a reparse point. Please also note that the indentation the remaining reparse point code changed, and hence the best way to look at this diff is with `--color-moved -w`. That code was _not_ moved because a subsequent commit will move it to an altogether different function, anyway. Signed-off-by: Karsten Blees Signed-off-by: Johannes Schindelin Signed-off-by: Junio C Hamano --- compat/mingw.c | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/compat/mingw.c b/compat/mingw.c index ec6c2801d3cbf4..23a926c7d14994 100644 --- a/compat/mingw.c +++ b/compat/mingw.c @@ -920,6 +920,7 @@ static int has_valid_directory_prefix(wchar_t *wfilename) int mingw_lstat(const char *file_name, struct stat *buf) { WIN32_FILE_ATTRIBUTE_DATA fdata; + WIN32_FIND_DATAW findbuf = { 0 }; wchar_t wfilename[MAX_PATH]; int wlen = xutftowcs_path(wfilename, file_name); if (wlen < 0) @@ -934,6 +935,13 @@ int mingw_lstat(const char *file_name, struct stat *buf) } if (GetFileAttributesExW(wfilename, GetFileExInfoStandard, &fdata)) { + /* for reparse points, use FindFirstFile to get the reparse tag */ + if (fdata.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) { + HANDLE handle = FindFirstFileW(wfilename, &findbuf); + if (handle == INVALID_HANDLE_VALUE) + goto error; + FindClose(handle); + } buf->st_ino = 0; buf->st_gid = 0; buf->st_uid = 0; @@ -946,20 +954,16 @@ int mingw_lstat(const char *file_name, struct stat *buf) filetime_to_timespec(&(fdata.ftLastWriteTime), &(buf->st_mtim)); filetime_to_timespec(&(fdata.ftCreationTime), &(buf->st_ctim)); if (fdata.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) { - WIN32_FIND_DATAW findbuf; - HANDLE handle = FindFirstFileW(wfilename, &findbuf); - if (handle != INVALID_HANDLE_VALUE) { - if ((findbuf.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) && - (findbuf.dwReserved0 == IO_REPARSE_TAG_SYMLINK)) { - buf->st_mode = S_IFLNK | S_IREAD; - if (!(findbuf.dwFileAttributes & FILE_ATTRIBUTE_READONLY)) - buf->st_mode |= S_IWRITE; - } - FindClose(handle); + if ((findbuf.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) && + (findbuf.dwReserved0 == IO_REPARSE_TAG_SYMLINK)) { + buf->st_mode = S_IFLNK | S_IREAD; + if (!(findbuf.dwFileAttributes & FILE_ATTRIBUTE_READONLY)) + buf->st_mode |= S_IWRITE; } } return 0; } +error: switch (GetLastError()) { case ERROR_ACCESS_DENIED: case ERROR_SHARING_VIOLATION: From 8a4f4131aad8d2b176f83df628ee3a8d6e19ba6c Mon Sep 17 00:00:00 2001 From: Karsten Blees Date: Fri, 9 Jan 2026 20:05:02 +0000 Subject: [PATCH 06/30] mingw: teach dirent about symlinks Move the `S_IFLNK` detection to `file_attr_to_st_mode()`. Implement `DT_LNK` detection in dirent.c's `readdir()` function. Signed-off-by: Karsten Blees Signed-off-by: Johannes Schindelin Signed-off-by: Junio C Hamano --- compat/mingw.c | 13 +++---------- compat/win32.h | 6 ++++-- compat/win32/dirent.c | 5 ++++- 3 files changed, 11 insertions(+), 13 deletions(-) diff --git a/compat/mingw.c b/compat/mingw.c index 23a926c7d14994..a3a48db581df87 100644 --- a/compat/mingw.c +++ b/compat/mingw.c @@ -946,21 +946,14 @@ int mingw_lstat(const char *file_name, struct stat *buf) buf->st_gid = 0; buf->st_uid = 0; buf->st_nlink = 1; - buf->st_mode = file_attr_to_st_mode(fdata.dwFileAttributes); + buf->st_mode = file_attr_to_st_mode(fdata.dwFileAttributes, + findbuf.dwReserved0); buf->st_size = fdata.nFileSizeLow | (((off_t)fdata.nFileSizeHigh)<<32); buf->st_dev = buf->st_rdev = 0; /* not used by Git */ filetime_to_timespec(&(fdata.ftLastAccessTime), &(buf->st_atim)); filetime_to_timespec(&(fdata.ftLastWriteTime), &(buf->st_mtim)); filetime_to_timespec(&(fdata.ftCreationTime), &(buf->st_ctim)); - if (fdata.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) { - if ((findbuf.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) && - (findbuf.dwReserved0 == IO_REPARSE_TAG_SYMLINK)) { - buf->st_mode = S_IFLNK | S_IREAD; - if (!(findbuf.dwFileAttributes & FILE_ATTRIBUTE_READONLY)) - buf->st_mode |= S_IWRITE; - } - } return 0; } error: @@ -1003,7 +996,7 @@ static int get_file_info_by_handle(HANDLE hnd, struct stat *buf) buf->st_gid = 0; buf->st_uid = 0; buf->st_nlink = 1; - buf->st_mode = file_attr_to_st_mode(fdata.dwFileAttributes); + buf->st_mode = file_attr_to_st_mode(fdata.dwFileAttributes, 0); buf->st_size = fdata.nFileSizeLow | (((off_t)fdata.nFileSizeHigh)<<32); buf->st_dev = buf->st_rdev = 0; /* not used by Git */ diff --git a/compat/win32.h b/compat/win32.h index a97e880757b6f1..671bcc81f93351 100644 --- a/compat/win32.h +++ b/compat/win32.h @@ -6,10 +6,12 @@ #include #endif -static inline int file_attr_to_st_mode (DWORD attr) +static inline int file_attr_to_st_mode (DWORD attr, DWORD tag) { int fMode = S_IREAD; - if (attr & FILE_ATTRIBUTE_DIRECTORY) + if ((attr & FILE_ATTRIBUTE_REPARSE_POINT) && tag == IO_REPARSE_TAG_SYMLINK) + fMode |= S_IFLNK; + else if (attr & FILE_ATTRIBUTE_DIRECTORY) fMode |= S_IFDIR; else fMode |= S_IFREG; diff --git a/compat/win32/dirent.c b/compat/win32/dirent.c index 52420ec7d4dad7..24ee9b814d6adf 100644 --- a/compat/win32/dirent.c +++ b/compat/win32/dirent.c @@ -12,7 +12,10 @@ static inline void finddata2dirent(struct dirent *ent, WIN32_FIND_DATAW *fdata) xwcstoutf(ent->d_name, fdata->cFileName, sizeof(ent->d_name)); /* Set file type, based on WIN32_FIND_DATA */ - if (fdata->dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) + if ((fdata->dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) + && fdata->dwReserved0 == IO_REPARSE_TAG_SYMLINK) + ent->d_type = DT_LNK; + else if (fdata->dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) ent->d_type = DT_DIR; else ent->d_type = DT_REG; From 543a91ccf9ba551eb563abb44eef0deadf3e38b7 Mon Sep 17 00:00:00 2001 From: Bill Zissimopoulos Date: Fri, 9 Jan 2026 20:05:03 +0000 Subject: [PATCH 07/30] mingw: compute the correct size for symlinks in `mingw_lstat()` POSIX specifies that upon successful return from `lstat()`: "the value of the st_size member shall be set to the length of the pathname contained in the symbolic link not including any terminating null byte". Git typically doesn't trust the `stat.st_size` member of symlinks (e.g. see `strbuf_readlink()`). Therefore, it is tempting to save on the extra overhead of opening and reading the reparse point merely to calculate the exact size of the link target. This is, in fact, what Git for Windows did, from May 2015 to May 2020. At least almost: some functions take shortcuts if `st_size` is 0 (e.g. `diff_populate_filespec()`), hence Git for Windows hard-coded the length of all symlinks to MAX_PATH. This did cause problems, though, specifically in Git repositories that were also accessed by Git for Cygwin or Git for WSL. For example, doing `git reset --hard` using Git for Windows would update the size of symlinks in the index to be MAX_PATH; at a later time Git for Cygwin or Git for WSL would find that symlinks have changed size during `git status` and update the index. And then Git for Windows would think that the index needs to be updated. Even if the symlinks did not, in fact, change. To avoid that, the correct size must be determined. Signed-off-by: Bill Zissimopoulos Signed-off-by: Johannes Schindelin Signed-off-by: Junio C Hamano --- compat/mingw.c | 114 ++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 104 insertions(+), 10 deletions(-) diff --git a/compat/mingw.c b/compat/mingw.c index a3a48db581df87..c7571951dc7bd9 100644 --- a/compat/mingw.c +++ b/compat/mingw.c @@ -21,6 +21,7 @@ #define SECURITY_WIN32 #include #include +#include #include #define STATUS_DELETE_PENDING ((NTSTATUS) 0xC0000056) @@ -917,10 +918,102 @@ static int has_valid_directory_prefix(wchar_t *wfilename) return 1; } +#ifndef _WINNT_H +/* + * The REPARSE_DATA_BUFFER structure is defined in the Windows DDK (in + * ntifs.h) and in MSYS1's winnt.h (which defines _WINNT_H). So define + * it ourselves if we are on MSYS2 (whose winnt.h defines _WINNT_). + */ +typedef struct _REPARSE_DATA_BUFFER { + DWORD ReparseTag; + WORD ReparseDataLength; + WORD Reserved; +#ifndef _MSC_VER + _ANONYMOUS_UNION +#endif + union { + struct { + WORD SubstituteNameOffset; + WORD SubstituteNameLength; + WORD PrintNameOffset; + WORD PrintNameLength; + ULONG Flags; + WCHAR PathBuffer[1]; + } SymbolicLinkReparseBuffer; + struct { + WORD SubstituteNameOffset; + WORD SubstituteNameLength; + WORD PrintNameOffset; + WORD PrintNameLength; + WCHAR PathBuffer[1]; + } MountPointReparseBuffer; + struct { + BYTE DataBuffer[1]; + } GenericReparseBuffer; + } DUMMYUNIONNAME; +} REPARSE_DATA_BUFFER, *PREPARSE_DATA_BUFFER; +#endif + +static int read_reparse_point(const WCHAR *wpath, BOOL fail_on_unknown_tag, + char *tmpbuf, int *plen, DWORD *ptag) +{ + HANDLE handle; + WCHAR *wbuf; + REPARSE_DATA_BUFFER *b = alloca(MAXIMUM_REPARSE_DATA_BUFFER_SIZE); + DWORD dummy; + + /* read reparse point data */ + handle = CreateFileW(wpath, 0, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, NULL, + OPEN_EXISTING, + FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT, NULL); + if (handle == INVALID_HANDLE_VALUE) { + errno = err_win_to_posix(GetLastError()); + return -1; + } + if (!DeviceIoControl(handle, FSCTL_GET_REPARSE_POINT, NULL, 0, b, + MAXIMUM_REPARSE_DATA_BUFFER_SIZE, &dummy, NULL)) { + errno = err_win_to_posix(GetLastError()); + CloseHandle(handle); + return -1; + } + CloseHandle(handle); + + /* get target path for symlinks or mount points (aka 'junctions') */ + switch ((*ptag = b->ReparseTag)) { + case IO_REPARSE_TAG_SYMLINK: + wbuf = (WCHAR*) (((char*) b->SymbolicLinkReparseBuffer.PathBuffer) + + b->SymbolicLinkReparseBuffer.SubstituteNameOffset); + *(WCHAR*) (((char*) wbuf) + + b->SymbolicLinkReparseBuffer.SubstituteNameLength) = 0; + break; + case IO_REPARSE_TAG_MOUNT_POINT: + wbuf = (WCHAR*) (((char*) b->MountPointReparseBuffer.PathBuffer) + + b->MountPointReparseBuffer.SubstituteNameOffset); + *(WCHAR*) (((char*) wbuf) + + b->MountPointReparseBuffer.SubstituteNameLength) = 0; + break; + default: + if (fail_on_unknown_tag) { + errno = EINVAL; + return -1; + } else { + *plen = MAX_PATH; + return 0; + } + } + + if ((*plen = + xwcstoutf(tmpbuf, normalize_ntpath(wbuf), MAX_PATH)) < 0) + return -1; + return 0; +} + int mingw_lstat(const char *file_name, struct stat *buf) { WIN32_FILE_ATTRIBUTE_DATA fdata; - WIN32_FIND_DATAW findbuf = { 0 }; + DWORD reparse_tag = 0; + int link_len = 0; wchar_t wfilename[MAX_PATH]; int wlen = xutftowcs_path(wfilename, file_name); if (wlen < 0) @@ -935,28 +1028,29 @@ int mingw_lstat(const char *file_name, struct stat *buf) } if (GetFileAttributesExW(wfilename, GetFileExInfoStandard, &fdata)) { - /* for reparse points, use FindFirstFile to get the reparse tag */ + /* for reparse points, get the link tag and length */ if (fdata.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) { - HANDLE handle = FindFirstFileW(wfilename, &findbuf); - if (handle == INVALID_HANDLE_VALUE) - goto error; - FindClose(handle); + char tmpbuf[MAX_PATH]; + + if (read_reparse_point(wfilename, FALSE, tmpbuf, + &link_len, &reparse_tag) < 0) + return -1; } buf->st_ino = 0; buf->st_gid = 0; buf->st_uid = 0; buf->st_nlink = 1; buf->st_mode = file_attr_to_st_mode(fdata.dwFileAttributes, - findbuf.dwReserved0); - buf->st_size = fdata.nFileSizeLow | - (((off_t)fdata.nFileSizeHigh)<<32); + reparse_tag); + buf->st_size = S_ISLNK(buf->st_mode) ? link_len : + fdata.nFileSizeLow | (((off_t) fdata.nFileSizeHigh) << 32); buf->st_dev = buf->st_rdev = 0; /* not used by Git */ filetime_to_timespec(&(fdata.ftLastAccessTime), &(buf->st_atim)); filetime_to_timespec(&(fdata.ftLastWriteTime), &(buf->st_mtim)); filetime_to_timespec(&(fdata.ftCreationTime), &(buf->st_ctim)); return 0; } -error: + switch (GetLastError()) { case ERROR_ACCESS_DENIED: case ERROR_SHARING_VIOLATION: From b0b32ff16ffc0448b4522997667086e31715424f Mon Sep 17 00:00:00 2001 From: Karsten Blees Date: Fri, 9 Jan 2026 20:05:04 +0000 Subject: [PATCH 08/30] mingw: factor out the retry logic In several places, Git's Windows-specific code follows the pattern where it tries to perform an operation, and retries several times when that operation fails, sleeping an increasing amount of time, before finally giving up and asking the user whether to rety (after, say, closing an editor that held a handle to a file, preventing the operation from succeeding). This logic is a bit hard to use, and inconsistent: `mingw_unlink()` and `mingw_rmdir()` duplicate the code to retry, and both of them do so incompletely. They also do not restore `errno` if the user answers 'no'. Introduce a `retry_ask_yes_no()` helper function that handles retry with small delay, asking the user, and restoring `errno`. Note that in `mingw_unlink()`, we include the `_wchmod()` call in the retry loop (which may fail if the file is locked exclusively). In `mingw_rmdir()`, we include special error handling in the retry loop. Signed-off-by: Karsten Blees Signed-off-by: Johannes Schindelin Signed-off-by: Junio C Hamano --- compat/mingw.c | 104 ++++++++++++++++++++++--------------------------- 1 file changed, 46 insertions(+), 58 deletions(-) diff --git a/compat/mingw.c b/compat/mingw.c index c7571951dc7bd9..26e64c6a5a2997 100644 --- a/compat/mingw.c +++ b/compat/mingw.c @@ -28,8 +28,6 @@ #define HCAST(type, handle) ((type)(intptr_t)handle) -static const int delay[] = { 0, 1, 10, 20, 40 }; - void open_in_gdb(void) { static struct child_process cp = CHILD_PROCESS_INIT; @@ -205,15 +203,12 @@ static int read_yes_no_answer(void) return -1; } -static int ask_yes_no_if_possible(const char *format, ...) +static int ask_yes_no_if_possible(const char *format, va_list args) { char question[4096]; const char *retry_hook; - va_list args; - va_start(args, format); vsnprintf(question, sizeof(question), format, args); - va_end(args); retry_hook = mingw_getenv("GIT_ASK_YESNO"); if (retry_hook) { @@ -238,6 +233,31 @@ static int ask_yes_no_if_possible(const char *format, ...) } } +static int retry_ask_yes_no(int *tries, const char *format, ...) +{ + static const int delay[] = { 0, 1, 10, 20, 40 }; + va_list args; + int result, saved_errno = errno; + + if ((*tries) < ARRAY_SIZE(delay)) { + /* + * We assume that some other process had the file open at the wrong + * moment and retry. In order to give the other process a higher + * chance to complete its operation, we give up our time slice now. + * If we have to retry again, we do sleep a bit. + */ + Sleep(delay[*tries]); + (*tries)++; + return 1; + } + + va_start(args, format); + result = ask_yes_no_if_possible(format, args); + va_end(args); + errno = saved_errno; + return result; +} + /* Windows only */ enum hide_dotfiles_type { HIDE_DOTFILES_FALSE = 0, @@ -298,7 +318,7 @@ static wchar_t *normalize_ntpath(wchar_t *wbuf) int mingw_unlink(const char *pathname, int handle_in_use_error) { - int ret, tries = 0; + int tries = 0; wchar_t wpathname[MAX_PATH]; if (xutftowcs_path(wpathname, pathname) < 0) return -1; @@ -306,29 +326,19 @@ int mingw_unlink(const char *pathname, int handle_in_use_error) if (DeleteFileW(wpathname)) return 0; - /* read-only files cannot be removed */ - _wchmod(wpathname, 0666); - while ((ret = _wunlink(wpathname)) == -1 && tries < ARRAY_SIZE(delay)) { + do { + /* read-only files cannot be removed */ + _wchmod(wpathname, 0666); + if (!_wunlink(wpathname)) + return 0; if (!is_file_in_use_error(GetLastError())) break; if (!handle_in_use_error) - return ret; + return -1; - /* - * We assume that some other process had the source or - * destination file open at the wrong moment and retry. - * In order to give the other process a higher chance to - * complete its operation, we give up our time slice now. - * If we have to retry again, we do sleep a bit. - */ - Sleep(delay[tries]); - tries++; - } - while (ret == -1 && is_file_in_use_error(GetLastError()) && - ask_yes_no_if_possible("Unlink of file '%s' failed. " - "Should I try again?", pathname)) - ret = _wunlink(wpathname); - return ret; + } while (retry_ask_yes_no(&tries, "Unlink of file '%s' failed. " + "Should I try again?", pathname)); + return -1; } static int is_dir_empty(const wchar_t *wpath) @@ -355,7 +365,7 @@ static int is_dir_empty(const wchar_t *wpath) int mingw_rmdir(const char *pathname) { - int ret, tries = 0; + int tries = 0; wchar_t wpathname[MAX_PATH]; struct stat st; @@ -381,7 +391,11 @@ int mingw_rmdir(const char *pathname) if (xutftowcs_path(wpathname, pathname) < 0) return -1; - while ((ret = _wrmdir(wpathname)) == -1 && tries < ARRAY_SIZE(delay)) { + do { + if (!_wrmdir(wpathname)) { + invalidate_lstat_cache(); + return 0; + } if (!is_file_in_use_error(GetLastError())) errno = err_win_to_posix(GetLastError()); if (errno != EACCES) @@ -390,23 +404,9 @@ int mingw_rmdir(const char *pathname) errno = ENOTEMPTY; break; } - /* - * We assume that some other process had the source or - * destination file open at the wrong moment and retry. - * In order to give the other process a higher chance to - * complete its operation, we give up our time slice now. - * If we have to retry again, we do sleep a bit. - */ - Sleep(delay[tries]); - tries++; - } - while (ret == -1 && errno == EACCES && is_file_in_use_error(GetLastError()) && - ask_yes_no_if_possible("Deletion of directory '%s' failed. " - "Should I try again?", pathname)) - ret = _wrmdir(wpathname); - if (!ret) - invalidate_lstat_cache(); - return ret; + } while (retry_ask_yes_no(&tries, "Deletion of directory '%s' failed. " + "Should I try again?", pathname)); + return -1; } static inline int needs_hiding(const char *path) @@ -2384,20 +2384,8 @@ int mingw_rename(const char *pold, const char *pnew) SetFileAttributesW(wpnew, attrs); } } - if (tries < ARRAY_SIZE(delay) && gle == ERROR_ACCESS_DENIED) { - /* - * We assume that some other process had the source or - * destination file open at the wrong moment and retry. - * In order to give the other process a higher chance to - * complete its operation, we give up our time slice now. - * If we have to retry again, we do sleep a bit. - */ - Sleep(delay[tries]); - tries++; - goto repeat; - } if (gle == ERROR_ACCESS_DENIED && - ask_yes_no_if_possible("Rename from '%s' to '%s' failed. " + retry_ask_yes_no(&tries, "Rename from '%s' to '%s' failed. " "Should I try again?", pold, pnew)) goto repeat; From 1bf1ffadc862c072e6cf27c67fcc72d4da23a492 Mon Sep 17 00:00:00 2001 From: Karsten Blees Date: Fri, 9 Jan 2026 20:05:05 +0000 Subject: [PATCH 09/30] mingw: change default of `core.symlinks` to false Symlinks on Windows don't work the same way as on Unix systems. For example, there are different types of symlinks for directories and files, and unless using a recent-ish Windows version in Developer Mode, creating symlinks requires administrative privileges. By default, disable symlink support on Windows. That is, users explicitly have to enable it with `git config [--system|--global] core.symlinks true`; For convenience, `git init` (and `git clone`) will perform a test whether the current setup allows creating symlinks and will configure that setting in the repository config. The test suite ignores system / global config files. Allow testing *with* symlink support by checking if native symlinks are enabled in MSYS2 (via setting the special environment variable `MSYS=winsymlinks:nativestrict` to ask the MSYS2 runtime to enable creating symlinks). Note: This assumes that Git's test suite is run in MSYS2's Bash, which is true for the time being (an experiment to switch to BusyBox-w32 failed due to the experimental nature of BusyBox-w32). Signed-off-by: Karsten Blees Signed-off-by: Johannes Schindelin Signed-off-by: Junio C Hamano --- compat/mingw.c | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/compat/mingw.c b/compat/mingw.c index 26e64c6a5a2997..0fe00a5b703d07 100644 --- a/compat/mingw.c +++ b/compat/mingw.c @@ -2862,6 +2862,15 @@ static void setup_windows_environment(void) if (!tmp && (tmp = getenv("USERPROFILE"))) setenv("HOME", tmp, 1); } + + /* + * Change 'core.symlinks' default to false, unless native symlinks are + * enabled in MSys2 (via 'MSYS=winsymlinks:nativestrict'). Thus we can + * run the test suite (which doesn't obey config files) with or without + * symlink support. + */ + if (!(tmp = getenv("MSYS")) || !strstr(tmp, "winsymlinks:nativestrict")) + has_symlinks = 0; } static void get_current_user_sid(PSID *sid, HANDLE *linked_token) From 9ac15c2ae3d4d7caf27444930f2e3d12a6eebee8 Mon Sep 17 00:00:00 2001 From: Karsten Blees Date: Fri, 9 Jan 2026 20:05:06 +0000 Subject: [PATCH 10/30] mingw: add symlink-specific error codes The Win32 API calls do not set `errno`; Instead, error codes for failed operations must be obtained via the `GetLastError()` function. Git would not know what to do with those error values, though, which is why Git's Windows compatibility layer translates them to `errno` values. Let's handle a couple of symlink-related error codes that will become relevant with the upcoming support for symlinks on Windows. Signed-off-by: Karsten Blees Signed-off-by: Johannes Schindelin Signed-off-by: Junio C Hamano --- compat/mingw.c | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/compat/mingw.c b/compat/mingw.c index 0fe00a5b703d07..0e8807196fcbed 100644 --- a/compat/mingw.c +++ b/compat/mingw.c @@ -102,6 +102,7 @@ int err_win_to_posix(DWORD winerr) case ERROR_INVALID_PARAMETER: error = EINVAL; break; case ERROR_INVALID_PASSWORD: error = EPERM; break; case ERROR_INVALID_PRIMARY_GROUP: error = EINVAL; break; + case ERROR_INVALID_REPARSE_DATA: error = EINVAL; break; case ERROR_INVALID_SIGNAL_NUMBER: error = EINVAL; break; case ERROR_INVALID_TARGET_HANDLE: error = EIO; break; case ERROR_INVALID_WORKSTATION: error = EACCES; break; @@ -116,6 +117,7 @@ int err_win_to_posix(DWORD winerr) case ERROR_NEGATIVE_SEEK: error = ESPIPE; break; case ERROR_NOACCESS: error = EFAULT; break; case ERROR_NONE_MAPPED: error = EINVAL; break; + case ERROR_NOT_A_REPARSE_POINT: error = EINVAL; break; case ERROR_NOT_ENOUGH_MEMORY: error = ENOMEM; break; case ERROR_NOT_READY: error = EAGAIN; break; case ERROR_NOT_SAME_DEVICE: error = EXDEV; break; @@ -136,6 +138,9 @@ int err_win_to_posix(DWORD winerr) case ERROR_PIPE_NOT_CONNECTED: error = EPIPE; break; case ERROR_PRIVILEGE_NOT_HELD: error = EACCES; break; case ERROR_READ_FAULT: error = EIO; break; + case ERROR_REPARSE_ATTRIBUTE_CONFLICT: error = EINVAL; break; + case ERROR_REPARSE_TAG_INVALID: error = EINVAL; break; + case ERROR_REPARSE_TAG_MISMATCH: error = EINVAL; break; case ERROR_SEEK: error = EIO; break; case ERROR_SEEK_ON_DEVICE: error = ESPIPE; break; case ERROR_SHARING_BUFFER_EXCEEDED: error = ENFILE; break; From ac41bfa374d67023970b26dc7117c878dfb58d06 Mon Sep 17 00:00:00 2001 From: Karsten Blees Date: Fri, 9 Jan 2026 20:05:07 +0000 Subject: [PATCH 11/30] mingw: handle symlinks to directories in `mingw_unlink()` The `_wunlink()` and `DeleteFileW()` functions refuse to delete symlinks to directories on Windows; The error code would be `ERROR_ACCESS_DENIED` in that case. Take that error code as an indicator that we need to try `_wrmdir()` as well. In the best case, it will remove a symlink. In the worst case, it will fail with the same error code again. Signed-off-by: Karsten Blees Signed-off-by: Johannes Schindelin Signed-off-by: Junio C Hamano --- compat/mingw.c | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/compat/mingw.c b/compat/mingw.c index 0e8807196fcbed..b1cc30d0f13922 100644 --- a/compat/mingw.c +++ b/compat/mingw.c @@ -338,9 +338,16 @@ int mingw_unlink(const char *pathname, int handle_in_use_error) return 0; if (!is_file_in_use_error(GetLastError())) break; + /* + * _wunlink() / DeleteFileW() for directory symlinks fails with + * ERROR_ACCESS_DENIED (EACCES), so try _wrmdir() as well. This is the + * same error we get if a file is in use (already checked above). + */ + if (!_wrmdir(wpathname)) + return 0; + if (!handle_in_use_error) return -1; - } while (retry_ask_yes_no(&tries, "Unlink of file '%s' failed. " "Should I try again?", pathname)); return -1; From 5e88e98c04267b44e4f822f297c60e1e8c852411 Mon Sep 17 00:00:00 2001 From: Karsten Blees Date: Fri, 9 Jan 2026 20:05:08 +0000 Subject: [PATCH 12/30] mingw: support renaming symlinks Older MSVCRT's `_wrename()` function cannot rename symlinks over existing files: it returns success without doing anything. Newer MSVCR*.dll versions probably do not share this problem: according to CRT sources, they just call `MoveFileEx()` with the `MOVEFILE_COPY_ALLOWED` flag. Avoid the `_wrename()` call, and go with directly calling `MoveFileEx()`, with proper error handling of course. Signed-off-by: Karsten Blees Signed-off-by: Johannes Schindelin Signed-off-by: Junio C Hamano --- compat/mingw.c | 38 ++++++++++++++++---------------------- 1 file changed, 16 insertions(+), 22 deletions(-) diff --git a/compat/mingw.c b/compat/mingw.c index b1cc30d0f13922..55f0bb478e6127 100644 --- a/compat/mingw.c +++ b/compat/mingw.c @@ -2275,7 +2275,7 @@ int mingw_accept(int sockfd1, struct sockaddr *sa, socklen_t *sz) int mingw_rename(const char *pold, const char *pnew) { static int supports_file_rename_info_ex = 1; - DWORD attrs, gle; + DWORD attrs = INVALID_FILE_ATTRIBUTES, gle; int tries = 0; wchar_t wpold[MAX_PATH], wpnew[MAX_PATH]; int wpnew_len; @@ -2286,15 +2286,6 @@ int mingw_rename(const char *pold, const char *pnew) if (wpnew_len < 0) return -1; - /* - * Try native rename() first to get errno right. - * It is based on MoveFile(), which cannot overwrite existing files. - */ - if (!_wrename(wpold, wpnew)) - return 0; - if (errno != EEXIST) - return -1; - repeat: if (supports_file_rename_info_ex) { /* @@ -2370,13 +2361,22 @@ int mingw_rename(const char *pold, const char *pnew) * to retry. */ } else { - if (MoveFileExW(wpold, wpnew, MOVEFILE_REPLACE_EXISTING)) + if (MoveFileExW(wpold, wpnew, + MOVEFILE_REPLACE_EXISTING | MOVEFILE_COPY_ALLOWED)) return 0; gle = GetLastError(); } - /* TODO: translate more errors */ - if (gle == ERROR_ACCESS_DENIED && + /* revert file attributes on failure */ + if (attrs != INVALID_FILE_ATTRIBUTES) + SetFileAttributesW(wpnew, attrs); + + if (!is_file_in_use_error(gle)) { + errno = err_win_to_posix(gle); + return -1; + } + + if (attrs == INVALID_FILE_ATTRIBUTES && (attrs = GetFileAttributesW(wpnew)) != INVALID_FILE_ATTRIBUTES) { if (attrs & FILE_ATTRIBUTE_DIRECTORY) { DWORD attrsold = GetFileAttributesW(wpold); @@ -2388,16 +2388,10 @@ int mingw_rename(const char *pold, const char *pnew) return -1; } if ((attrs & FILE_ATTRIBUTE_READONLY) && - SetFileAttributesW(wpnew, attrs & ~FILE_ATTRIBUTE_READONLY)) { - if (MoveFileExW(wpold, wpnew, MOVEFILE_REPLACE_EXISTING)) - return 0; - gle = GetLastError(); - /* revert file attributes on failure */ - SetFileAttributesW(wpnew, attrs); - } + SetFileAttributesW(wpnew, attrs & ~FILE_ATTRIBUTE_READONLY)) + goto repeat; } - if (gle == ERROR_ACCESS_DENIED && - retry_ask_yes_no(&tries, "Rename from '%s' to '%s' failed. " + if (retry_ask_yes_no(&tries, "Rename from '%s' to '%s' failed. " "Should I try again?", pold, pnew)) goto repeat; From 43745a7d55daa1aa0937a3c6e8b521bd026a31f1 Mon Sep 17 00:00:00 2001 From: Karsten Blees Date: Fri, 9 Jan 2026 20:05:09 +0000 Subject: [PATCH 13/30] mingw: allow `mingw_chdir()` to change to symlink-resolved directories If symlinks are enabled, resolve all symlinks when changing directories, as required by POSIX. Note: Git's `real_path()` function bases its link resolution algorithm on this property of `chdir()`. Unfortunately, the current directory on Windows is limited to only MAX_PATH (260) characters. Therefore using symlinks and long paths in combination may be problematic. Signed-off-by: Karsten Blees Signed-off-by: Johannes Schindelin Signed-off-by: Junio C Hamano --- compat/mingw.c | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/compat/mingw.c b/compat/mingw.c index 55f0bb478e6127..5d2a8c247ce622 100644 --- a/compat/mingw.c +++ b/compat/mingw.c @@ -866,9 +866,27 @@ int mingw_access(const char *filename, int mode) int mingw_chdir(const char *dirname) { wchar_t wdirname[MAX_PATH]; + if (xutftowcs_path(wdirname, dirname) < 0) return -1; - return _wchdir(wdirname); + + if (has_symlinks) { + HANDLE hnd = CreateFileW(wdirname, 0, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, NULL, + OPEN_EXISTING, FILE_FLAG_BACKUP_SEMANTICS, NULL); + if (hnd == INVALID_HANDLE_VALUE) { + errno = err_win_to_posix(GetLastError()); + return -1; + } + if (!GetFinalPathNameByHandleW(hnd, wdirname, ARRAY_SIZE(wdirname), 0)) { + errno = err_win_to_posix(GetLastError()); + CloseHandle(hnd); + return -1; + } + CloseHandle(hnd); + } + + return _wchdir(normalize_ntpath(wdirname)); } int mingw_chmod(const char *filename, int mode) From 980852dbff657a14eecc4a8a72a2874dad2bad71 Mon Sep 17 00:00:00 2001 From: Karsten Blees Date: Fri, 9 Jan 2026 20:05:10 +0000 Subject: [PATCH 14/30] mingw: implement `readlink()` Implement `readlink()` by reading NTFS reparse points via the `read_reparse_point()` function that was introduced earlier to determine the length of symlink targets. Works for symlinks and directory junctions. Signed-off-by: Karsten Blees Signed-off-by: Johannes Schindelin Signed-off-by: Junio C Hamano --- compat/mingw-posix.h | 3 +-- compat/mingw.c | 24 ++++++++++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/compat/mingw-posix.h b/compat/mingw-posix.h index 0939feff27ffec..896aa976b1949b 100644 --- a/compat/mingw-posix.h +++ b/compat/mingw-posix.h @@ -121,8 +121,6 @@ struct utsname { * trivial stubs */ -static inline int readlink(const char *path UNUSED, char *buf UNUSED, size_t bufsiz UNUSED) -{ errno = ENOSYS; return -1; } static inline int symlink(const char *oldpath UNUSED, const char *newpath UNUSED) { errno = ENOSYS; return -1; } static inline int fchmod(int fildes UNUSED, mode_t mode UNUSED) @@ -197,6 +195,7 @@ int setitimer(int type, struct itimerval *in, struct itimerval *out); int sigaction(int sig, struct sigaction *in, struct sigaction *out); int link(const char *oldpath, const char *newpath); int uname(struct utsname *buf); +int readlink(const char *path, char *buf, size_t bufsiz); /* * replacements of existing functions diff --git a/compat/mingw.c b/compat/mingw.c index 5d2a8c247ce622..b407a2ac07f9d4 100644 --- a/compat/mingw.c +++ b/compat/mingw.c @@ -2698,6 +2698,30 @@ int link(const char *oldpath, const char *newpath) return 0; } +int readlink(const char *path, char *buf, size_t bufsiz) +{ + WCHAR wpath[MAX_PATH]; + char tmpbuf[MAX_PATH]; + int len; + DWORD tag; + + if (xutftowcs_path(wpath, path) < 0) + return -1; + + if (read_reparse_point(wpath, TRUE, tmpbuf, &len, &tag) < 0) + return -1; + + /* + * Adapt to strange readlink() API: Copy up to bufsiz *bytes*, potentially + * cutting off a UTF-8 sequence. Insufficient bufsize is *not* a failure + * condition. There is no conversion function that produces invalid UTF-8, + * so convert to a (hopefully large enough) temporary buffer, then memcpy + * the requested number of bytes (including '\0' for robustness). + */ + memcpy(buf, tmpbuf, min(bufsiz, len + 1)); + return min(bufsiz, len); +} + pid_t waitpid(pid_t pid, int *status, int options) { HANDLE h = OpenProcess(SYNCHRONIZE | PROCESS_QUERY_INFORMATION, From 593008b95dfd4898f182d664d6b162c7590b4028 Mon Sep 17 00:00:00 2001 From: Karsten Blees Date: Fri, 9 Jan 2026 20:05:11 +0000 Subject: [PATCH 15/30] mingw: implement basic `symlink()` functionality (file symlinks only) Implement `symlink()`. This implementation always creates _file_ symlinks (remember: Windows discerns between symlinks pointing to directories and those pointing to files). Support for directory symlinks will be added in a subseqeuent commit. This implementation fails with `ENOSYS` if symlinks are disabled or unsupported. Signed-off-by: Karsten Blees Signed-off-by: Johannes Schindelin Signed-off-by: Junio C Hamano --- compat/mingw-posix.h | 3 +-- compat/mingw.c | 28 ++++++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/compat/mingw-posix.h b/compat/mingw-posix.h index 896aa976b1949b..2d989fd762474e 100644 --- a/compat/mingw-posix.h +++ b/compat/mingw-posix.h @@ -121,8 +121,6 @@ struct utsname { * trivial stubs */ -static inline int symlink(const char *oldpath UNUSED, const char *newpath UNUSED) -{ errno = ENOSYS; return -1; } static inline int fchmod(int fildes UNUSED, mode_t mode UNUSED) { errno = ENOSYS; return -1; } #ifndef __MINGW64_VERSION_MAJOR @@ -195,6 +193,7 @@ int setitimer(int type, struct itimerval *in, struct itimerval *out); int sigaction(int sig, struct sigaction *in, struct sigaction *out); int link(const char *oldpath, const char *newpath); int uname(struct utsname *buf); +int symlink(const char *target, const char *link); int readlink(const char *path, char *buf, size_t bufsiz); /* diff --git a/compat/mingw.c b/compat/mingw.c index b407a2ac07f9d4..8d366794c44e55 100644 --- a/compat/mingw.c +++ b/compat/mingw.c @@ -2698,6 +2698,34 @@ int link(const char *oldpath, const char *newpath) return 0; } +int symlink(const char *target, const char *link) +{ + wchar_t wtarget[MAX_PATH], wlink[MAX_PATH]; + int len; + + /* fail if symlinks are disabled or API is not supported (WinXP) */ + if (!has_symlinks) { + errno = ENOSYS; + return -1; + } + + if ((len = xutftowcs_path(wtarget, target)) < 0 + || xutftowcs_path(wlink, link) < 0) + return -1; + + /* convert target dir separators to backslashes */ + while (len--) + if (wtarget[len] == '/') + wtarget[len] = '\\'; + + /* create file symlink */ + if (!CreateSymbolicLinkW(wlink, wtarget, 0)) { + errno = err_win_to_posix(GetLastError()); + return -1; + } + return 0; +} + int readlink(const char *path, char *buf, size_t bufsiz) { WCHAR wpath[MAX_PATH]; From 97be7aa43a711646e66e1b11b43624e0940fe278 Mon Sep 17 00:00:00 2001 From: Karsten Blees Date: Fri, 9 Jan 2026 20:05:12 +0000 Subject: [PATCH 16/30] mingw: add support for symlinks to directories Symlinks on Windows have a flag that indicates whether the target is a file or a directory. Symlinks of wrong type simply don't work. This even affects core Win32 APIs (e.g. `DeleteFile()` refuses to delete directory symlinks). However, `CreateFile()` with FILE_FLAG_BACKUP_SEMANTICS does work. Check the target type by first creating a tentative file symlink, opening it, and checking the type of the resulting handle. If it is a directory, recreate the symlink with the directory flag set. It is possible to create symlinks before the target exists (or in case of symlinks to symlinks: before the target type is known). If this happens, create a tentative file symlink and postpone the directory decision: keep a list of phantom symlinks to be processed whenever a new directory is created in `mingw_mkdir()`. Limitations: This algorithm may fail if a link target changes from file to directory or vice versa, or if the target directory is created in another process. It's the best Git can do, though. Signed-off-by: Karsten Blees Signed-off-by: Johannes Schindelin Signed-off-by: Junio C Hamano --- compat/mingw.c | 164 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 164 insertions(+) diff --git a/compat/mingw.c b/compat/mingw.c index 8d366794c44e55..59a32e454ed7d1 100644 --- a/compat/mingw.c +++ b/compat/mingw.c @@ -296,6 +296,131 @@ int mingw_core_config(const char *var, const char *value, return 0; } +static inline int is_wdir_sep(wchar_t wchar) +{ + return wchar == L'/' || wchar == L'\\'; +} + +static const wchar_t *make_relative_to(const wchar_t *path, + const wchar_t *relative_to, wchar_t *out, + size_t size) +{ + size_t i = wcslen(relative_to), len; + + /* Is `path` already absolute? */ + if (is_wdir_sep(path[0]) || + (iswalpha(path[0]) && path[1] == L':' && is_wdir_sep(path[2]))) + return path; + + while (i > 0 && !is_wdir_sep(relative_to[i - 1])) + i--; + + /* Is `relative_to` in the current directory? */ + if (!i) + return path; + + len = wcslen(path); + if (i + len + 1 > size) { + error("Could not make '%ls' relative to '%ls' (too large)", + path, relative_to); + return NULL; + } + + memcpy(out, relative_to, i * sizeof(wchar_t)); + wcscpy(out + i, path); + return out; +} + +enum phantom_symlink_result { + PHANTOM_SYMLINK_RETRY, + PHANTOM_SYMLINK_DONE, + PHANTOM_SYMLINK_DIRECTORY +}; + +/* + * Changes a file symlink to a directory symlink if the target exists and is a + * directory. + */ +static enum phantom_symlink_result +process_phantom_symlink(const wchar_t *wtarget, const wchar_t *wlink) +{ + HANDLE hnd; + BY_HANDLE_FILE_INFORMATION fdata; + wchar_t relative[MAX_PATH]; + const wchar_t *rel; + + /* check that wlink is still a file symlink */ + if ((GetFileAttributesW(wlink) + & (FILE_ATTRIBUTE_REPARSE_POINT | FILE_ATTRIBUTE_DIRECTORY)) + != FILE_ATTRIBUTE_REPARSE_POINT) + return PHANTOM_SYMLINK_DONE; + + /* make it relative, if necessary */ + rel = make_relative_to(wtarget, wlink, relative, ARRAY_SIZE(relative)); + if (!rel) + return PHANTOM_SYMLINK_DONE; + + /* let Windows resolve the link by opening it */ + hnd = CreateFileW(rel, 0, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, NULL, + OPEN_EXISTING, FILE_FLAG_BACKUP_SEMANTICS, NULL); + if (hnd == INVALID_HANDLE_VALUE) { + errno = err_win_to_posix(GetLastError()); + return PHANTOM_SYMLINK_RETRY; + } + + if (!GetFileInformationByHandle(hnd, &fdata)) { + errno = err_win_to_posix(GetLastError()); + CloseHandle(hnd); + return PHANTOM_SYMLINK_RETRY; + } + CloseHandle(hnd); + + /* if target exists and is a file, we're done */ + if (!(fdata.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY)) + return PHANTOM_SYMLINK_DONE; + + /* otherwise recreate the symlink with directory flag */ + if (DeleteFileW(wlink) && CreateSymbolicLinkW(wlink, wtarget, 1)) + return PHANTOM_SYMLINK_DIRECTORY; + + errno = err_win_to_posix(GetLastError()); + return PHANTOM_SYMLINK_RETRY; +} + +/* keep track of newly created symlinks to non-existing targets */ +struct phantom_symlink_info { + struct phantom_symlink_info *next; + wchar_t *wlink; + wchar_t *wtarget; +}; + +static struct phantom_symlink_info *phantom_symlinks = NULL; +static CRITICAL_SECTION phantom_symlinks_cs; + +static void process_phantom_symlinks(void) +{ + struct phantom_symlink_info *current, **psi; + EnterCriticalSection(&phantom_symlinks_cs); + /* process phantom symlinks list */ + psi = &phantom_symlinks; + while ((current = *psi)) { + enum phantom_symlink_result result = process_phantom_symlink( + current->wtarget, current->wlink); + if (result == PHANTOM_SYMLINK_RETRY) { + psi = ¤t->next; + } else { + /* symlink was processed, remove from list */ + *psi = current->next; + free(current); + /* if symlink was a directory, start over */ + if (result == PHANTOM_SYMLINK_DIRECTORY) + psi = &phantom_symlinks; + } + } + LeaveCriticalSection(&phantom_symlinks_cs); +} + /* Normalizes NT paths as returned by some low-level APIs. */ static wchar_t *normalize_ntpath(wchar_t *wbuf) { @@ -479,6 +604,8 @@ int mingw_mkdir(const char *path, int mode UNUSED) if (xutftowcs_path(wpath, path) < 0) return -1; ret = _wmkdir(wpath); + if (!ret) + process_phantom_symlinks(); if (!ret && needs_hiding(path)) return set_hidden_flag(wpath, 1); return ret; @@ -2723,6 +2850,42 @@ int symlink(const char *target, const char *link) errno = err_win_to_posix(GetLastError()); return -1; } + + /* convert to directory symlink if target exists */ + switch (process_phantom_symlink(wtarget, wlink)) { + case PHANTOM_SYMLINK_RETRY: { + /* if target doesn't exist, add to phantom symlinks list */ + wchar_t wfullpath[MAX_PATH]; + struct phantom_symlink_info *psi; + + /* convert to absolute path to be independent of cwd */ + len = GetFullPathNameW(wlink, MAX_PATH, wfullpath, NULL); + if (!len || len >= MAX_PATH) { + errno = err_win_to_posix(GetLastError()); + return -1; + } + + /* over-allocate and fill phantom_symlink_info structure */ + psi = xmalloc(sizeof(struct phantom_symlink_info) + + sizeof(wchar_t) * (len + wcslen(wtarget) + 2)); + psi->wlink = (wchar_t *)(psi + 1); + wcscpy(psi->wlink, wfullpath); + psi->wtarget = psi->wlink + len + 1; + wcscpy(psi->wtarget, wtarget); + + EnterCriticalSection(&phantom_symlinks_cs); + psi->next = phantom_symlinks; + phantom_symlinks = psi; + LeaveCriticalSection(&phantom_symlinks_cs); + break; + } + case PHANTOM_SYMLINK_DIRECTORY: + /* if we created a dir symlink, process other phantom symlinks */ + process_phantom_symlinks(); + break; + default: + break; + } return 0; } @@ -3424,6 +3587,7 @@ int wmain(int argc, const wchar_t **wargv) /* initialize critical section for waitpid pinfo_t list */ InitializeCriticalSection(&pinfo_cs); + InitializeCriticalSection(&phantom_symlinks_cs); /* set up default file mode and file modes for stdin/out/err */ _fmode = _O_BINARY; From 6206f7aeb0c584c91d226e32d446d6d062fa9674 Mon Sep 17 00:00:00 2001 From: Johannes Schindelin Date: Fri, 9 Jan 2026 20:05:13 +0000 Subject: [PATCH 17/30] mingw: try to create symlinks without elevated permissions As of Windows 10 Build 14972 in Developer Mode, a new flag is supported by `CreateSymbolicLink()` to create symbolic links even when running outside of an elevated session (which was previously required). This new flag is called `SYMBOLIC_LINK_FLAG_ALLOW_UNPRIVILEGED_CREATE` and has the numeric value 0x02. Previous Windows 10 versions will not understand that flag and return an `ERROR_INVALID_PARAMETER`, therefore we have to be careful to try passing that flag only when the build number indicates that it is supported. For more information about the new flag, see this blog post: https://blogs.windows.com/buildingapps/2016/12/02/symlinks-windows-10/ Signed-off-by: Johannes Schindelin Signed-off-by: Junio C Hamano --- compat/mingw.c | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/compat/mingw.c b/compat/mingw.c index 59a32e454ed7d1..3e2110a87ae35d 100644 --- a/compat/mingw.c +++ b/compat/mingw.c @@ -331,6 +331,8 @@ static const wchar_t *make_relative_to(const wchar_t *path, return out; } +static DWORD symlink_file_flags = 0, symlink_directory_flags = 1; + enum phantom_symlink_result { PHANTOM_SYMLINK_RETRY, PHANTOM_SYMLINK_DONE, @@ -381,7 +383,8 @@ process_phantom_symlink(const wchar_t *wtarget, const wchar_t *wlink) return PHANTOM_SYMLINK_DONE; /* otherwise recreate the symlink with directory flag */ - if (DeleteFileW(wlink) && CreateSymbolicLinkW(wlink, wtarget, 1)) + if (DeleteFileW(wlink) && + CreateSymbolicLinkW(wlink, wtarget, symlink_directory_flags)) return PHANTOM_SYMLINK_DIRECTORY; errno = err_win_to_posix(GetLastError()); @@ -2846,7 +2849,7 @@ int symlink(const char *target, const char *link) wtarget[len] = '\\'; /* create file symlink */ - if (!CreateSymbolicLinkW(wlink, wtarget, 0)) { + if (!CreateSymbolicLinkW(wlink, wtarget, symlink_file_flags)) { errno = err_win_to_posix(GetLastError()); return -1; } @@ -3523,6 +3526,24 @@ static void maybe_redirect_std_handles(void) GENERIC_WRITE, FILE_FLAG_NO_BUFFERING); } +static void adjust_symlink_flags(void) +{ + /* + * Starting with Windows 10 Build 14972, symbolic links can be created + * using CreateSymbolicLink() without elevation by passing the flag + * SYMBOLIC_LINK_FLAG_ALLOW_UNPRIVILEGED_CREATE (0x02) as last + * parameter, provided the Developer Mode has been enabled. Some + * earlier Windows versions complain about this flag with an + * ERROR_INVALID_PARAMETER, hence we have to test the build number + * specifically. + */ + if (GetVersion() >= 14972 << 16) { + symlink_file_flags |= 2; + symlink_directory_flags |= 2; + } + +} + #ifdef _MSC_VER #ifdef _DEBUG #include @@ -3558,6 +3579,7 @@ int wmain(int argc, const wchar_t **wargv) #endif maybe_redirect_std_handles(); + adjust_symlink_flags(); /* determine size of argv and environ conversion buffer */ maxlen = wcslen(wargv[0]); From 2cba5746c03f85fedd45a08b4f91921c5960bb36 Mon Sep 17 00:00:00 2001 From: Johannes Schindelin Date: Fri, 9 Jan 2026 20:05:14 +0000 Subject: [PATCH 18/30] mingw: emulate `stat()` a little more faithfully When creating directories via `safe_create_leading_directories()`, we might encounter an already-existing directory which is not readable by the current user. To handle that situation, Git's code calls `stat()` to determine whether we're looking at a directory. In such a case, `CreateFile()` will fail, though, no matter what, and consequently `mingw_stat()` will fail, too. But POSIX semantics seem to still allow `stat()` to go forward. So let's call `mingw_lstat()` to the rescue if we fail to get a file handle due to denied permission in `mingw_stat()`, and fill the stat info that way. We need to be careful to not allow this to go forward in case that we're looking at a symbolic link: to resolve the link, we would still have to create a file handle, and we just found out that we cannot. Therefore, `stat()` still needs to fail with `EACCES` in that case. This fixes https://github.com/git-for-windows/git/issues/2531. Signed-off-by: Johannes Schindelin Signed-off-by: Junio C Hamano --- compat/mingw.c | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/compat/mingw.c b/compat/mingw.c index 3e2110a87ae35d..628a3941d24647 100644 --- a/compat/mingw.c +++ b/compat/mingw.c @@ -1273,7 +1273,19 @@ int mingw_stat(const char *file_name, struct stat *buf) FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, NULL, OPEN_EXISTING, FILE_FLAG_BACKUP_SEMANTICS, NULL); if (hnd == INVALID_HANDLE_VALUE) { - errno = err_win_to_posix(GetLastError()); + DWORD err = GetLastError(); + + if (err == ERROR_ACCESS_DENIED && + !mingw_lstat(file_name, buf) && + !S_ISLNK(buf->st_mode)) + /* + * POSIX semantics state to still try to fill + * information, even if permission is denied to create + * a file handle. + */ + return 0; + + errno = err_win_to_posix(err); return -1; } result = get_file_info_by_handle(hnd, buf); From 44af34bde7db9430b31a5891c3d1e6d34fefae76 Mon Sep 17 00:00:00 2001 From: Johannes Schindelin Date: Fri, 9 Jan 2026 20:05:15 +0000 Subject: [PATCH 19/30] mingw: special-case index entries for symlinks with buggy size In https://github.com/git-for-windows/git/pull/2637, we fixed a bug where symbolic links' target path sizes were recorded incorrectly in the index. The downside of this fix was that every user with tracked symbolic links in their checkouts would see them as modified in `git status`, but not in `git diff`, and only a `git add ` (or `git add -u`) would "fix" this. Let's do better than that: we can detect that situation and simply pretend that a symbolic link with a known bad size (or a size that just happens to be that bad size, a _very_ unlikely scenario because it would overflow our buffers due to the trailing NUL byte) means that it needs to be re-checked as if we had just checked it out. Signed-off-by: Johannes Schindelin Signed-off-by: Junio C Hamano --- read-cache.c | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/read-cache.c b/read-cache.c index 990d4ead0d8ae4..260f4b3b2fd55c 100644 --- a/read-cache.c +++ b/read-cache.c @@ -470,6 +470,17 @@ int ie_modified(struct index_state *istate, * then we know it is. */ if ((changed & DATA_CHANGED) && +#ifdef GIT_WINDOWS_NATIVE + /* + * Work around Git for Windows v2.27.0 fixing a bug where symlinks' + * target path lengths were not read at all, and instead recorded + * as 4096: now, all symlinks would appear as modified. + * + * So let's just special-case symlinks with a target path length + * (i.e. `sd_size`) of 4096 and force them to be re-checked. + */ + (!S_ISLNK(st->st_mode) || ce->ce_stat_data.sd_size != MAX_PATH) && +#endif (S_ISGITLINK(ce->ce_mode) || ce->ce_stat_data.sd_size != 0)) return changed; From c381a2149082397b07eaf4b96ff67375d2aab159 Mon Sep 17 00:00:00 2001 From: Ramsay Jones Date: Fri, 16 Jan 2026 20:39:44 +0000 Subject: [PATCH 20/30] t9700/test.pl: fix path type expectation on cygwin Commit 4ec7ac101b ("t9700: accommodate for Windows paths", 2025-12-17) changed the type of the absolute path to the git directory from unix to win32 for both GfW and cygwin. This fixed the test for GfW but causes new failures on cygwin, since the test expectation is that it uses unix paths on cygwin. In order to not break cygwin, disable the new code by removing the "or $^O eq 'cygwin'" sub-expression from the conditional part of the fix. Signed-off-by: Ramsay Jones Signed-off-by: Junio C Hamano --- t/t9700/test.pl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/t/t9700/test.pl b/t/t9700/test.pl index 570b0c5680fc73..f83e6169e2c100 100755 --- a/t/t9700/test.pl +++ b/t/t9700/test.pl @@ -118,7 +118,7 @@ sub adjust_dirsep { # paths my $abs_git_dir = $abs_repo_dir . "/.git"; -if ($^O eq 'msys' or $^O eq 'cygwin') { +if ($^O eq 'msys') { $abs_git_dir = `cygpath -am "$abs_repo_dir/.git"`; $abs_git_dir =~ s/\r?\n?$//; } From 59da9f292a1da89d4f6972dc2f8dfd225c01cc21 Mon Sep 17 00:00:00 2001 From: Ramsay Jones Date: Fri, 16 Jan 2026 20:39:56 +0000 Subject: [PATCH 21/30] t0610-reftable-basics: mitigate a flaky test on cygwin Test #29 ('ref transaction: corrupted tables cause failure') started to fail intermittently for me (from v2.52.0-rc0) when running the testsuite with '-j8'. (Also, having moved to a new laptop and windows 11, rather than windows 10). If the test is run by hand, or without any parallelism, then it passes without issue. When the test fails (e.g. 1 out of 32 parallel runs) the cause is due to a permission error while corrupting a table file: ./test-lib.sh: line 1010: .git/reftable/0x000000000001-0x000000000002-d89bb8ee.ref: Permission denied This corruption is done in a shell loop, directly after a 'test_commit', which uses an ': >"$f"' expression to truncate the file. Adding a sleep of one second after the 'test_commit' and before the shell loop fixes the test (it is not clear why). Replacing the redirection shell expression with a 'test-tool truncate "$f" 0' invocation also provides a fix, which could simply be another way to change the timing sufficiently to win the race. During a debug session, I tried looking at the strace output for the shell redirection: $ rm /tmp/hello; echo hello >/tmp/hello; ls -l /tmp/hello -rw-r--r-- 1 ramsay None 6 Nov 10 17:25 /tmp/hello $ $ strace -o zzz bash -c ': >/tmp/hello' $ Similarly, for the test-tool solution: $ strace -o xxx ./t/helper/test-tool truncate /tmp/hello 0 $ When comparing the output, the differences seemed to be what you would expect and, if anything, the shell redirect probably would have taken longer than the test-tool solution (many fcntl() calls to dup the stdout to the ). The call to the win32 api NtCreateFile() was identical, apart from the first (FileHandle) parameter, of course. In order to fix this flaky test on cygwin, despite not knowing why it works, replace the shell redirection with the above 'test-tool truncate' invocation. Helped-by: Patrick Steinhardt Signed-off-by: Ramsay Jones Signed-off-by: Junio C Hamano --- t/t0610-reftable-basics.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/t/t0610-reftable-basics.sh b/t/t0610-reftable-basics.sh index 6575528f212716..e19e0368989d50 100755 --- a/t/t0610-reftable-basics.sh +++ b/t/t0610-reftable-basics.sh @@ -207,7 +207,7 @@ test_expect_success 'ref transaction: corrupted tables cause failure' ' test_commit file1 && for f in .git/reftable/*.ref do - : >"$f" || return 1 + test-tool truncate "$f" 0 || return 1 done && test_must_fail git update-ref refs/heads/main HEAD ) From de985d69f66960cb512b38f2e7bc83d2a92d391e Mon Sep 17 00:00:00 2001 From: Jiang Xin Date: Sat, 17 Jan 2026 21:59:38 +0800 Subject: [PATCH 22/30] help: report on whether or not gettext is enabled When users report that Git has no localized output, we need to check not only their locale settings, but also whether Git was built with GETTEXT support in the first place. Expose this information via the existing build info output by adding a "gettext: enabled" line to `git version --build-options` (and therefore also to `git bugreport`) when `NO_GETTEXT` is not defined at build time. Signed-off-by: Jiang Xin Signed-off-by: Junio C Hamano --- help.c | 3 +++ 1 file changed, 3 insertions(+) diff --git a/help.c b/help.c index 5854dd4a7e468b..8d3e4f3f6420b3 100644 --- a/help.c +++ b/help.c @@ -799,6 +799,9 @@ void get_version_info(struct strbuf *buf, int show_build_options) if (fsmonitor_ipc__is_supported()) strbuf_addstr(buf, "feature: fsmonitor--daemon\n"); +#if !defined NO_GETTEXT + strbuf_addstr(buf, "gettext: enabled\n"); +#endif #if defined LIBCURL_VERSION strbuf_addf(buf, "libcurl: %s\n", LIBCURL_VERSION); #endif From bc8556d06652c50bb0ab03dd75fe31a1909975a4 Mon Sep 17 00:00:00 2001 From: Tian Yuchen Date: Sat, 17 Jan 2026 14:25:15 +0800 Subject: [PATCH 23/30] t1005: modernize "! test -f" to "test_path_is_missing" Replace instances of "! test -f " with "test_path_is_missing ". This macro provides better diagnostics when the test fails (it prints "Path exists:" instead of silently failing). Signed-off-by: Tian Yuchen Signed-off-by: Junio C Hamano --- t/t1005-read-tree-reset.sh | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/t/t1005-read-tree-reset.sh b/t/t1005-read-tree-reset.sh index 6b5033d0ce3b34..1bf384dd0f8c4d 100755 --- a/t/t1005-read-tree-reset.sh +++ b/t/t1005-read-tree-reset.sh @@ -40,7 +40,7 @@ test_expect_success 'reset should remove remnants from a failed merge' ' git ls-files -s && read_tree_u_must_succeed --reset -u HEAD && git ls-files -s >actual && - ! test -f old && + test_path_is_missing old && test_cmp expect actual ' @@ -56,7 +56,7 @@ test_expect_success 'two-way reset should remove remnants too' ' git ls-files -s && read_tree_u_must_succeed --reset -u HEAD HEAD && git ls-files -s >actual && - ! test -f old && + test_path_is_missing old && test_cmp expect actual ' @@ -72,7 +72,7 @@ test_expect_success 'Porcelain reset should remove remnants too' ' git ls-files -s && git reset --hard && git ls-files -s >actual && - ! test -f old && + test_path_is_missing old && test_cmp expect actual ' @@ -88,7 +88,7 @@ test_expect_success 'Porcelain checkout -f should remove remnants too' ' git ls-files -s && git checkout -f && git ls-files -s >actual && - ! test -f old && + test_path_is_missing old && test_cmp expect actual ' @@ -104,7 +104,7 @@ test_expect_success 'Porcelain checkout -f HEAD should remove remnants too' ' git ls-files -s && git checkout -f HEAD && git ls-files -s >actual && - ! test -f old && + test_path_is_missing old && test_cmp expect actual ' From d7971544fe17378f44f49983010dbfc1834f7bef Mon Sep 17 00:00:00 2001 From: Johannes Schindelin Date: Fri, 16 Jan 2026 17:31:16 +0000 Subject: [PATCH 24/30] ci(*-leaks): skip the git-svn tests to save time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I noticed recently that the leak-checking jobs still take a lot of time, and upon analysis, the git-svn tests contribute significantly to this. Analyzing a recent CI run, I saw that the Git test suite contains 1,017 tests, running for approximately 5¼ hours total. Of these, 65 git-svn-related tests (~6% of test count) took 42.24 minutes combined, accounting for ~13.% of the total runtime. This implies that the git-svn tests are roughly twice as expernsive compared to the other tests. However, testing git-svn in the leak-checking jobs provides minimal value: git-svn is implemented as a Perl script, and leak checking only handles C code. While git-svn does call into Git's built-in commands that are implemented in C, these are standard Git operations that are already thoroughly exercised elsewhere in the test suite. Therefore, running the git-svn tests in the leak-checking jobs only adds to the overall run time with little value in return. Given that the leak-checking jobs are particularly time-intensive and these 42+ minutes of SVN tests per job provide no additional leak detection value, skip them in the *-leaks jobs to reduce CI runtime. Assisted-by: Claude Sonnet 4.5 Signed-off-by: Johannes Schindelin Signed-off-by: Junio C Hamano --- ci/lib.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/ci/lib.sh b/ci/lib.sh index f561884d40166c..a165c7f2683bf6 100755 --- a/ci/lib.sh +++ b/ci/lib.sh @@ -356,6 +356,7 @@ linux-musl-meson) ;; linux-leaks|linux-reftable-leaks) export SANITIZE=leak + export NO_SVN_TESTS=LetsSaveSomeTime ;; linux-asan-ubsan) export SANITIZE=address,undefined From 047bd7dfe3b6563ea5f6543533207e3481f3e74c Mon Sep 17 00:00:00 2001 From: Junio C Hamano Date: Sat, 17 Jan 2026 10:34:17 -0800 Subject: [PATCH 25/30] ci: skip CVS and P4 tests in leaks job, too Looking at the CI logs, the p4 and cvs tests account for another 24 minutes of test time and they offer minimal value for quite a similar reason as the previous step. Let's introduce and use a mechanism to skip these tests to save some resources. Suggested-by: Phillip Wood Signed-off-by: Junio C Hamano --- ci/lib.sh | 2 ++ t/lib-cvs.sh | 6 ++++++ t/lib-git-p4.sh | 5 +++++ 3 files changed, 13 insertions(+) diff --git a/ci/lib.sh b/ci/lib.sh index a165c7f2683bf6..3ecbf147db4fc1 100755 --- a/ci/lib.sh +++ b/ci/lib.sh @@ -356,7 +356,9 @@ linux-musl-meson) ;; linux-leaks|linux-reftable-leaks) export SANITIZE=leak + export NO_CVS_TESTS=LetsSaveSomeTime export NO_SVN_TESTS=LetsSaveSomeTime + export NO_P4_TESTS=LetsSaveSomeTime ;; linux-asan-ubsan) export SANITIZE=address,undefined diff --git a/t/lib-cvs.sh b/t/lib-cvs.sh index 57b9b2db9b3f82..c8b44048883dab 100644 --- a/t/lib-cvs.sh +++ b/t/lib-cvs.sh @@ -2,6 +2,12 @@ . ./test-lib.sh +if test -n "$NO_CVS_TESTS" +then + skip_all='skipping git cvs tests, NO_CVS_TESTS defined' + test_done +fi + unset CVS_SERVER if ! type cvs >/dev/null 2>&1 diff --git a/t/lib-git-p4.sh b/t/lib-git-p4.sh index 2a5b8738ea37da..d22e9c684a495a 100644 --- a/t/lib-git-p4.sh +++ b/t/lib-git-p4.sh @@ -16,6 +16,11 @@ P4D_TIMEOUT=300 . ./test-lib.sh +if test -n "$NO_P4_TESTS" +then + skip_all='skipping git p4 tests, NO_P4_TESTS defined' + test_done +fi if ! test_have_prereq PYTHON then skip_all='skipping git p4 tests; python not available' From 28cfac364ff4c0ce595d9048f727e1e6fb3fbf42 Mon Sep 17 00:00:00 2001 From: Phillip Wood Date: Tue, 20 Jan 2026 11:01:55 +0000 Subject: [PATCH 26/30] mailmap: add an entry for Phillip Wood While all my commits appear under the same address, other addresses appear in some commit trailers. Map those addresses to the canonical one. Signed-off-by: Phillip Wood Signed-off-by: Junio C Hamano --- .mailmap | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.mailmap b/.mailmap index 7b3198171fad1e..3be50d67cb189c 100644 --- a/.mailmap +++ b/.mailmap @@ -226,6 +226,8 @@ Philip Jägenstedt Philip Oakley # secondary Philipp A. Hartmann Philippe Bruhat +Phillip Wood +Phillip Wood Ralf Thielow Ramsay Jones Ramkumar Ramachandra From ad228c24df15e96f98737a9751a455e278b62fcb Mon Sep 17 00:00:00 2001 From: "D. Ben Knoble" Date: Tue, 20 Jan 2026 09:05:57 -0500 Subject: [PATCH 27/30] replay: drop rev-list formatting options from manual MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rev-list options in our manuals are quite long; git-replay's manual is no exception. Since replay doesn't use the formatting options at all (it has its own output format), drop them. This is the first time we have needed compound tests [1] for if[n]def in our documentation: git grep '^ifn\?def::' Documentation | grep '[,+]' [1]: https://docs.asciidoctor.org/asciidoc/latest/directives/ifdef-ifndef/ For both ifdef and ifndef, the "," takes on the intuitive meaning: - ifdef: if any of the listed attributes are set… - ifndef: unless any of the listed attributes are set (Use "+" for "all".) Signed-off-by: D. Ben Knoble Signed-off-by: Junio C Hamano --- Documentation/git-replay.adoc | 1 + Documentation/rev-list-options.adoc | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/Documentation/git-replay.adoc b/Documentation/git-replay.adoc index 4c61f3aa1f1c70..c3b214ec6964c7 100644 --- a/Documentation/git-replay.adoc +++ b/Documentation/git-replay.adoc @@ -64,6 +64,7 @@ The default mode can be configured via the `replay.refAction` configuration vari range should have a single tip, so that it's clear to which tip the advanced should point. +:git-replay: 1 include::rev-list-options.adoc[] [[output]] diff --git a/Documentation/rev-list-options.adoc b/Documentation/rev-list-options.adoc index 453ec590571ffc..c4d7a6b989fd0f 100644 --- a/Documentation/rev-list-options.adoc +++ b/Documentation/rev-list-options.adoc @@ -1096,7 +1096,7 @@ endif::git-rev-list[] Overrides a previous `--no-walk`. endif::git-shortlog[] -ifndef::git-shortlog[] +ifndef::git-shortlog,git-replay[] Commit Formatting ~~~~~~~~~~~~~~~~~ @@ -1265,4 +1265,4 @@ ifdef::git-rev-list[] counts and print the count for equivalent commits separated by a tab. endif::git-rev-list[] -endif::git-shortlog[] +endif::git-shortlog,git-replay[] From 46933bf1825eb436b8e3ae6aba067344e0a8bfae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-No=C3=ABl=20Avila?= Date: Wed, 21 Jan 2026 14:27:05 +0100 Subject: [PATCH 28/30] lint-gitlink: preemptively ignore all /ifn?def|endif/ macros MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Instead of testing if the macro name is ifn?def:: as if it were a inline macro, it is faster and safer to just ignore such block macro lines before hand. Signed-off-by: Jean-Noël Avila Signed-off-by: Junio C Hamano --- Documentation/lint-gitlink.perl | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Documentation/lint-gitlink.perl b/Documentation/lint-gitlink.perl index f183a18df28466..a92e887b4c75f2 100755 --- a/Documentation/lint-gitlink.perl +++ b/Documentation/lint-gitlink.perl @@ -41,10 +41,11 @@ sub report { @ARGV = $to_check; while (<>) { my $line = $_; + next if $line =~ /^\s*(ifn?def|endif)::/; while ($line =~ m/(.{,8})((git[-a-z]+|scalar)\[(\d)*\])/g) { my $pos = pos $line; my ($macro, $target, $page, $section) = ($1, $2, $3, $4); - if ( $macro ne "linkgit:" && $macro !~ "ifn?def::" && $macro ne "endif::" ) { + if ( $macro ne "linkgit:" ) { report($pos, $line, $target, "linkgit: macro expected"); } } From 7dfd1b4c138596c5b5369dca2102b80fe42e95c0 Mon Sep 17 00:00:00 2001 From: Kristoffer Haugsbakk Date: Wed, 21 Jan 2026 22:51:09 +0100 Subject: [PATCH 29/30] =?UTF-8?q?.mailmap:=20fix=20and=20expand=20mappings?= =?UTF-8?q?=20for=20Jean-No=C3=ABl=20Avila?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The latest release candidate notes say that there is a new contributor: Jean-Noël Avila via GitGitGadget, ... But this is a familiar face, just in a G.G. Gadget trench coat. Also map the rest of the idents in the history. Signed-off-by: Kristoffer Haugsbakk Signed-off-by: Junio C Hamano --- .mailmap | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.mailmap b/.mailmap index 3cf26b1add06cd..799734821b4b9a 100644 --- a/.mailmap +++ b/.mailmap @@ -107,6 +107,9 @@ Jason Riedy Jay Soffian Jean-Noël Avila Jean-Noel Avila Jean-Noël Avila Jean-Noël AVILA +Jean-Noël Avila Jean-Noel Avila +Jean-Noël Avila Jean-Noël AVILA +Jean-Noël Avila Jean-Noël Avila via GitGitGadget Jeff King Jeff Muizelaar Jens Axboe From ea24e2c55433012a0a6c4ae947a87bc66404e484 Mon Sep 17 00:00:00 2001 From: Junio C Hamano Date: Fri, 23 Jan 2026 13:34:17 -0800 Subject: [PATCH 30/30] A bit more before -rc2 Signed-off-by: Junio C Hamano --- Documentation/RelNotes/2.53.0.adoc | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/Documentation/RelNotes/2.53.0.adoc b/Documentation/RelNotes/2.53.0.adoc index be8df254774caa..8ae76e0f3e2b99 100644 --- a/Documentation/RelNotes/2.53.0.adoc +++ b/Documentation/RelNotes/2.53.0.adoc @@ -42,8 +42,10 @@ UI, Workflows & Features deal better with rebased history. * The iconv library on macOS fails to correctly handle stateful - ISO/IEC 2022 encoded strings. Work it around instead of replacing - it wholesale from homebrew. + ISO/IEC 2022:1994 encoded strings. Work it around instead of + replacing it wholesale from homebrew. + + * Upstream symbolic link support on Windows from Git-for-Windows. Performance, Internal Implementation, Development Support etc. @@ -114,6 +116,17 @@ Performance, Internal Implementation, Development Support etc. around tree objects and make it explicit which repository to work in. + * "git bugreport" and "git version --build-options" learned to + include use of 'gettext' feature, to make it easier to diagnose + problems around l10n. + + * Dscho observed that SVN tests are taking too much time in CI leak + checking tasks, but most time is spent not in our code but in libsvn + code (which happen to be written in Perl), whose leaks have little + value to discover for us. Skip SVN, P4, and CVS tests in the leak + checking tasks. + (merge 047bd7dfe3 js/ci-leak-skip-svn later to maint). + Fixes since v2.52 ----------------- @@ -322,3 +335,5 @@ Fixes since v2.52 (merge 555c8464e5 je/doc-reset later to maint). (merge 220f888d7e ps/t1410-cleanup later to maint). (merge 5814b04c02 ps/config-doc-get-urlmatch-fix later to maint). + (merge 5ae594f30b sb/doc-update-ref-markup-fix later to maint). + (merge bc8556d066 ty/t1005-test-path-is-helpers later to maint).