From 6f4126b23bc94583932f575d8bef2a46fbcff394 Mon Sep 17 00:00:00 2001 From: Fadhlaoui Raed Date: Tue, 3 Nov 2026 17:58:58 -0500 Subject: [PATCH 1/5] fix(utils): convert Git Bash (MSYS) paths to Windows paths on Windows On Windows, Git Bash / MSYS reports repository paths as POSIX "mount" paths such as `/c/Users/foo` instead of `C:\Users\foo`. Passing these to Node's filesystem APIs resolves `/c` as a directory under the current drive root (e.g. `C:\c\Users\foo`), so the build fails with an ENOENT on a path like `P:\p\projets\...` instead of `P:\projets\...`. Normalize the output of `git rev-parse --show-toplevel` and `--show-superproject-working-tree` on Windows, converting a `//...` mount path back to a real Windows path before calling `fs.realpath`. Standard Git-for-Windows output (`C:/...`) and POSIX paths are unchanged. --- .../src/vcs/__tests__/gitUtils.test.ts | 22 ++++++++++++++++++ packages/docusaurus-utils/src/vcs/gitUtils.ts | 23 +++++++++++++++++-- 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/packages/docusaurus-utils/src/vcs/__tests__/gitUtils.test.ts b/packages/docusaurus-utils/src/vcs/__tests__/gitUtils.test.ts index e7167269cd9b..03c3774d17ce 100644 --- a/packages/docusaurus-utils/src/vcs/__tests__/gitUtils.test.ts +++ b/packages/docusaurus-utils/src/vcs/__tests__/gitUtils.test.ts @@ -20,6 +20,7 @@ import { getGitSubmodulePaths, getGitAllRepoRoots, getGitRepositoryFilesInfo, + fromGitBashPath, } from '../gitUtils'; import {createVcsGitEagerConfig} from '../vcsGitEager'; @@ -448,6 +449,27 @@ describe('getGitRepoRoot', () => { }); }); +describe('fromGitBashPath', () => { + it('converts Git Bash / MSYS mount paths to Windows paths', () => { + expect(fromGitBashPath('/p/projets/my-repo')).toBe('P:\\projets\\my-repo'); + expect(fromGitBashPath('/c/dev/docusaurus')).toBe('C:\\dev\\docusaurus'); + expect(fromGitBashPath('/d/a/b/c')).toBe('D:\\a\\b\\c'); + }); + + it('upper-cases the drive letter', () => { + expect(fromGitBashPath('/e/folder')).toBe('E:\\folder'); + }); + + it('leaves regular paths unchanged', () => { + // Standard Git for Windows output (drive letter + forward slashes) + expect(fromGitBashPath('C:/dev/docusaurus')).toBe('C:/dev/docusaurus'); + // POSIX paths that are not single-letter mount paths + expect(fromGitBashPath('/home/user/repo')).toBe('/home/user/repo'); + expect(fromGitBashPath('/usr/local')).toBe('/usr/local'); + expect(fromGitBashPath('/c')).toBe('/c'); + }); +}); + describe('submodules APIs', () => { async function initTestRepo() { const superproject = await createGitRepoEmpty(); diff --git a/packages/docusaurus-utils/src/vcs/gitUtils.ts b/packages/docusaurus-utils/src/vcs/gitUtils.ts index 447f28524d89..e1022b1999b1 100644 --- a/packages/docusaurus-utils/src/vcs/gitUtils.ts +++ b/packages/docusaurus-utils/src/vcs/gitUtils.ts @@ -254,6 +254,25 @@ export async function isGitInsideWorktree(cwd: string): Promise { } } +// Git Bash / MSYS on Windows reports paths as POSIX "mount" paths such as +// `/c/Users/foo` instead of `C:\Users\foo`. Node's path APIs treat the leading +// `/c` as a directory under the current drive root (resolving to `C:\c\Users\foo`), +// which then fails with ENOENT. Convert such a path back to a real Windows path. +// Exported for testing. +export function fromGitBashPath(gitPath: string): string { + const match = /^\/([a-zA-Z])\/(.*)$/.exec(gitPath); + if (!match) { + return gitPath; + } + return `${match[1]!.toUpperCase()}:\\${match[2]!.replaceAll('/', '\\')}`; +} + +// Normalize a path returned by Git before handing it to the filesystem. Only +// needed on Windows, where Git Bash may return MSYS mount paths (see above). +function normalizeGitPath(gitPath: string): string { + return process.platform === 'win32' ? fromGitBashPath(gitPath) : gitPath; +} + export async function getGitRepoRoot(cwd: string): Promise { const result = await execa('git', ['rev-parse', '--show-toplevel'], { cwd, @@ -269,7 +288,7 @@ The command executed throws an error: ${error.message}`, ); }); - return fs.realpath(result.stdout.trim()); + return fs.realpath(normalizeGitPath(result.stdout.trim())); } // A Git "superproject" is a Git repository that contains submodules @@ -300,7 +319,7 @@ The command executed throws an error: ${error.message}`, // this command only works when inside submodules // otherwise it doesn't return anything when we are inside the main repo if (output) { - return fs.realpath(output); + return fs.realpath(normalizeGitPath(output)); } return getGitRepoRoot(cwd); } From e98e8641bdd305b5a1a62e0e8870adb5d06caead Mon Sep 17 00:00:00 2001 From: Fadhlaoui Raed Date: Tue, 3 Nov 2026 18:13:24 -0500 Subject: [PATCH 2/5] fix(utils): also convert Git Bash drive-root paths (e.g. `/c`) Make the subpath optional in fromGitBashPath so a repository located at a drive root, which Git Bash reports as `/c`, maps to `C:\` instead of being passed through unchanged to fs.realpath. --- .../docusaurus-utils/src/vcs/__tests__/gitUtils.test.ts | 8 +++++++- packages/docusaurus-utils/src/vcs/gitUtils.ts | 8 ++++++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/packages/docusaurus-utils/src/vcs/__tests__/gitUtils.test.ts b/packages/docusaurus-utils/src/vcs/__tests__/gitUtils.test.ts index 03c3774d17ce..6b040b5667df 100644 --- a/packages/docusaurus-utils/src/vcs/__tests__/gitUtils.test.ts +++ b/packages/docusaurus-utils/src/vcs/__tests__/gitUtils.test.ts @@ -460,13 +460,19 @@ describe('fromGitBashPath', () => { expect(fromGitBashPath('/e/folder')).toBe('E:\\folder'); }); + it('converts a repository located at a drive root', () => { + expect(fromGitBashPath('/c')).toBe('C:\\'); + expect(fromGitBashPath('/c/')).toBe('C:\\'); + }); + it('leaves regular paths unchanged', () => { // Standard Git for Windows output (drive letter + forward slashes) expect(fromGitBashPath('C:/dev/docusaurus')).toBe('C:/dev/docusaurus'); // POSIX paths that are not single-letter mount paths expect(fromGitBashPath('/home/user/repo')).toBe('/home/user/repo'); expect(fromGitBashPath('/usr/local')).toBe('/usr/local'); - expect(fromGitBashPath('/c')).toBe('/c'); + expect(fromGitBashPath('/usr')).toBe('/usr'); + expect(fromGitBashPath('/cc')).toBe('/cc'); }); }); diff --git a/packages/docusaurus-utils/src/vcs/gitUtils.ts b/packages/docusaurus-utils/src/vcs/gitUtils.ts index e1022b1999b1..1c0aa926bd51 100644 --- a/packages/docusaurus-utils/src/vcs/gitUtils.ts +++ b/packages/docusaurus-utils/src/vcs/gitUtils.ts @@ -260,11 +260,15 @@ export async function isGitInsideWorktree(cwd: string): Promise { // which then fails with ENOENT. Convert such a path back to a real Windows path. // Exported for testing. export function fromGitBashPath(gitPath: string): string { - const match = /^\/([a-zA-Z])\/(.*)$/.exec(gitPath); + // Match `/c`, `/c/`, or `/c/sub/path`. The subpath is optional so a repository + // located at a drive root (reported by Git Bash as `/c`) maps to `C:\` instead + // of being left unchanged. + const match = /^\/([a-zA-Z])(?:\/(.*))?$/.exec(gitPath); if (!match) { return gitPath; } - return `${match[1]!.toUpperCase()}:\\${match[2]!.replaceAll('/', '\\')}`; + const subpath = match[2] ?? ''; + return `${match[1]!.toUpperCase()}:\\${subpath.replaceAll('/', '\\')}`; } // Normalize a path returned by Git before handing it to the filesystem. Only From 814d3b2d8bb454966d8c13cbf5524724f16a2a67 Mon Sep 17 00:00:00 2001 From: Fadhlaoui Raed Date: Tue, 3 Nov 2026 18:35:04 -0500 Subject: [PATCH 3/5] test(utils): assert the Git root resolvers normalize paths on Windows Add Windows-mode tests for getGitRepoRoot and getGitSuperProjectRoot that stub the Git command output, force process.platform to win32, and assert each resolver passes a normalized Windows path to fs.realpath. The tests fail if normalizeGitPath is removed from either resolver. They live in a separate file so their module mocks do not affect the real-Git integration tests. --- .../vcs/__tests__/gitUtils.windows.test.ts | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 packages/docusaurus-utils/src/vcs/__tests__/gitUtils.windows.test.ts diff --git a/packages/docusaurus-utils/src/vcs/__tests__/gitUtils.windows.test.ts b/packages/docusaurus-utils/src/vcs/__tests__/gitUtils.windows.test.ts new file mode 100644 index 000000000000..f06664791642 --- /dev/null +++ b/packages/docusaurus-utils/src/vcs/__tests__/gitUtils.windows.test.ts @@ -0,0 +1,65 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {execa, type Result} from 'execa'; +import {fs} from '@docusaurus/fs'; +import {getGitRepoRoot, getGitSuperProjectRoot} from '../gitUtils'; + +// This file mocks execa and the filesystem, so it lives apart from the +// integration tests in gitUtils.test.ts (which run against real Git repos). +// Vitest scopes module mocks per file, so the two do not interfere. +vi.mock('execa', () => ({ + execa: vi.fn(), + execaSync: vi.fn(() => ({exitCode: 0})), +})); + +vi.mock('@docusaurus/fs', () => ({ + fs: {realpath: vi.fn(async (p: string) => p)}, + pathExists: vi.fn(), +})); + +describe('Git root resolvers normalize Git Bash paths on Windows', () => { + const originalPlatform = process.platform; + + beforeEach(() => { + vi.clearAllMocks(); + Object.defineProperty(process, 'platform', { + value: 'win32', + configurable: true, + }); + }); + + afterEach(() => { + Object.defineProperty(process, 'platform', { + value: originalPlatform, + configurable: true, + }); + }); + + it('getGitRepoRoot passes a normalized Windows path to fs.realpath', async () => { + vi.mocked(execa).mockResolvedValue({ + stdout: '/c/Users/foo/project', + } as unknown as Result); + + await getGitRepoRoot('/any/cwd'); + + // fs.realpath would receive '/c/Users/foo/project' (and this would fail) if + // normalizeGitPath were removed from the resolver. + expect(fs.realpath).toHaveBeenCalledWith('C:\\Users\\foo\\project'); + }); + + it('getGitSuperProjectRoot passes a normalized Windows path to fs.realpath', async () => { + vi.mocked(execa).mockResolvedValue({ + stdout: '/d/work/super', + } as unknown as Result); + + await getGitSuperProjectRoot('/any/cwd'); + + expect(fs.realpath).toHaveBeenCalledWith('D:\\work\\super'); + }); +}); From 7d2d33cdcffecffa6494c0491e44211da27276aa Mon Sep 17 00:00:00 2001 From: Fadhlaoui Raed Date: Tue, 3 Nov 2026 18:46:51 -0500 Subject: [PATCH 4/5] test(utils): assert POSIX Git paths pass through unchanged off Windows Add a non-Windows case to the resolver wiring tests: with process.platform set to linux, a Git-returned `/c/project` path reaches fs.realpath unchanged. This guards the win32 check in normalizeGitPath, so the test fails if that guard is removed. --- .../vcs/__tests__/gitUtils.windows.test.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/packages/docusaurus-utils/src/vcs/__tests__/gitUtils.windows.test.ts b/packages/docusaurus-utils/src/vcs/__tests__/gitUtils.windows.test.ts index f06664791642..787097c0200f 100644 --- a/packages/docusaurus-utils/src/vcs/__tests__/gitUtils.windows.test.ts +++ b/packages/docusaurus-utils/src/vcs/__tests__/gitUtils.windows.test.ts @@ -62,4 +62,23 @@ describe('Git root resolvers normalize Git Bash paths on Windows', () => { expect(fs.realpath).toHaveBeenCalledWith('D:\\work\\super'); }); + + it('leaves POSIX Git paths unchanged on non-Windows platforms', async () => { + // A `/c/project` path is a valid absolute path on Linux and must be passed + // through untouched; this fails if the win32 guard is removed from + // normalizeGitPath. + Object.defineProperty(process, 'platform', { + value: 'linux', + configurable: true, + }); + vi.mocked(execa) + .mockResolvedValueOnce({stdout: '/c/project'} as unknown as Result) + .mockResolvedValueOnce({stdout: '/c/project'} as unknown as Result); + + await getGitRepoRoot('/any/cwd'); + await getGitSuperProjectRoot('/any/cwd'); + + expect(fs.realpath).toHaveBeenNthCalledWith(1, '/c/project'); + expect(fs.realpath).toHaveBeenNthCalledWith(2, '/c/project'); + }); }); From b35bfc6c5dff7bdd440478ca58ecc01a12070cee Mon Sep 17 00:00:00 2001 From: Fadhlaoui Raed Date: Wed, 4 Nov 2026 10:02:29 -0500 Subject: [PATCH 5/5] style(utils): satisfy lint rules in the Git Bash path conversion Use the i flag and named capture groups in the drive-path regex, as regexp/use-ignore-case and prefer-named-capture-group require, and wrap the comments to 80 columns. Behaviour is unchanged. --- packages/docusaurus-utils/src/vcs/gitUtils.ts | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/docusaurus-utils/src/vcs/gitUtils.ts b/packages/docusaurus-utils/src/vcs/gitUtils.ts index 1c0aa926bd51..5cfd5479e902 100644 --- a/packages/docusaurus-utils/src/vcs/gitUtils.ts +++ b/packages/docusaurus-utils/src/vcs/gitUtils.ts @@ -255,20 +255,20 @@ export async function isGitInsideWorktree(cwd: string): Promise { } // Git Bash / MSYS on Windows reports paths as POSIX "mount" paths such as -// `/c/Users/foo` instead of `C:\Users\foo`. Node's path APIs treat the leading -// `/c` as a directory under the current drive root (resolving to `C:\c\Users\foo`), -// which then fails with ENOENT. Convert such a path back to a real Windows path. -// Exported for testing. +// `/c/Users/foo` instead of `C:\Users\foo`. Node's path APIs treat the +// leading `/c` as a directory under the current drive root (resolving to +// `C:\c\Users\foo`), which then fails with ENOENT. Convert such a path back +// to a real Windows path. Exported for testing. export function fromGitBashPath(gitPath: string): string { - // Match `/c`, `/c/`, or `/c/sub/path`. The subpath is optional so a repository - // located at a drive root (reported by Git Bash as `/c`) maps to `C:\` instead - // of being left unchanged. - const match = /^\/([a-zA-Z])(?:\/(.*))?$/.exec(gitPath); + // Match `/c`, `/c/`, or `/c/sub/path`. The subpath is optional so a + // repository located at a drive root (reported by Git Bash as `/c`) maps to + // `C:\` instead of being left unchanged. + const match = /^\/(?[a-z])(?:\/(?.*))?$/i.exec(gitPath); if (!match) { return gitPath; } - const subpath = match[2] ?? ''; - return `${match[1]!.toUpperCase()}:\\${subpath.replaceAll('/', '\\')}`; + const subpath = match.groups!.subpath ?? ''; + return `${match.groups!.drive!.toUpperCase()}:\\${subpath.replaceAll('/', '\\')}`; } // Normalize a path returned by Git before handing it to the filesystem. Only