diff --git a/packages/docusaurus-utils/src/vcs/__tests__/gitUtils.test.ts b/packages/docusaurus-utils/src/vcs/__tests__/gitUtils.test.ts index e7167269cd9b..6b040b5667df 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,33 @@ 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('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('/usr')).toBe('/usr'); + expect(fromGitBashPath('/cc')).toBe('/cc'); + }); +}); + describe('submodules APIs', () => { async function initTestRepo() { const superproject = await createGitRepoEmpty(); 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..787097c0200f --- /dev/null +++ b/packages/docusaurus-utils/src/vcs/__tests__/gitUtils.windows.test.ts @@ -0,0 +1,84 @@ +/** + * 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'); + }); + + 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'); + }); +}); diff --git a/packages/docusaurus-utils/src/vcs/gitUtils.ts b/packages/docusaurus-utils/src/vcs/gitUtils.ts index 447f28524d89..5cfd5479e902 100644 --- a/packages/docusaurus-utils/src/vcs/gitUtils.ts +++ b/packages/docusaurus-utils/src/vcs/gitUtils.ts @@ -254,6 +254,29 @@ 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 { + // 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.groups!.subpath ?? ''; + return `${match.groups!.drive!.toUpperCase()}:\\${subpath.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 +292,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 +323,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); }