Skip to content
Open
51 changes: 51 additions & 0 deletions .github/workflows/tests-windows-msys.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
name: Windows MSYS Tests

on:
pull_request:
branches:
- main
- docusaurus-v**
paths:
- packages/docusaurus-utils/src/vcs/**
- .github/workflows/tests-windows-msys.yml
- .github/actions/**

concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
cancel-in-progress: true

permissions:
contents: read

jobs:
windows-msys-build:
name: Windows MSYS Build
timeout-minutes: 30
runs-on: windows-latest
steps:
- name: Support longpaths
run: git config --system core.longpaths true
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Set up pnpm, Node.js and dependencies
uses: ./.github/actions/setup
with:
node-version: '26'
# Git for Windows prints native paths: only the MSYS2 build of Git prints
# the /d/a/... paths of https://github.com/meta/docusaurus/issues/11920
# "path-type: inherit" keeps Node.js on the PATH, after the MSYS2 tools
- name: Set up MSYS2
uses: msys2/setup-msys2@ec48f7c5447b3140e2b088413ae3a55687bccb6e # v2.33.0
with:
msystem: MSYS
path-type: inherit
install: git
- name: Docusaurus Build
shell: msys2 {0}
working-directory: website
run: |
# This job proves nothing if Git doesn't print an MSYS path
git rev-parse --show-toplevel | grep -E '^/[a-z]/'
./node_modules/.bin/docusaurus build --locale en
env:
BUILD_FAST: 'true'
28 changes: 28 additions & 0 deletions packages/docusaurus-utils/src/vcs/__tests__/gitUtils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
getGitSubmodulePaths,
getGitAllRepoRoots,
getGitRepositoryFilesInfo,
fromGitBashPath,
} from '../gitUtils';
import {createVcsGitEagerConfig} from '../vcsGitEager';

Expand Down Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
@@ -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');
});
});
27 changes: 25 additions & 2 deletions packages/docusaurus-utils/src/vcs/gitUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,29 @@ export async function isGitInsideWorktree(cwd: string): Promise<boolean> {
}
}

// 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 = /^\/(?<drive>[a-z])(?:\/(?<subpath>.*))?$/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<string> {
const result = await execa('git', ['rev-parse', '--show-toplevel'], {
cwd,
Expand All @@ -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
Expand Down Expand Up @@ -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);
}
Expand Down
2 changes: 2 additions & 0 deletions project-words.txt
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,7 @@ mkdn
mkdocs
mkdown
moonwave
MSYS
Nabors
Nakagawa
nand
Expand Down Expand Up @@ -219,6 +220,7 @@ printfn
producthunt
Profilo
profilo
projets
PRPL
Pyltsyn
QJPUV
Expand Down