Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 119 additions & 0 deletions .github/workflows/msys-git-path-repro.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
name: MSYS Git Path Repro

# Reproduces the MSYS/Cygwin Git path bug on Windows.
# See https://github.com/facebook/docusaurus/issues/11920
#
# Under Git Bash / MSYS2 / Cygwin, `git rev-parse --show-toplevel` prints an
# MSYS-style path like `/c/Users/.../site`. The previous getGitRepoRoot passed
# that straight to `fs.realpath.native`, which resolves it against the current
# drive root and duplicates the drive letter into `C:\c\Users\...`, a path that
# does not exist. This job runs the real getGitRepoRoot from inside an MSYS2
# shell and proves both the bug and the fix in a single run.

on:
workflow_dispatch:
inputs:
mode:
description: 'fixed (assert the fix) or nofix (assert the old broken behaviour, expected to fail)'
required: false
default: 'fixed'
type: choice
options:
- fixed
- nofix
pull_request:
branches:
- main
- docusaurus-v**
paths:
- packages/docusaurus-utils/src/pathUtils.ts
- packages/docusaurus-utils/src/vcs/gitUtils.ts
- admin/scripts/msys-git-path-repro/**
- .github/workflows/msys-git-path-repro.yml
push:
branches:
- fix-git-msys-path-windows

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

permissions:
contents: read

jobs:
msys-repro:
name: MSYS Git Path Repro
timeout-minutes: 30
runs-on: windows-latest
steps:
- name: Support longpaths
run: git config --system core.longpaths true

- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

- name: Use Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: '24.14'
cache: yarn

- name: Installation
run: yarn install --frozen-lockfile || yarn install --frozen-lockfile || yarn install --frozen-lockfile

- name: Build packages
run: yarn build:packages

# Provide the MSYS2 build of Git. path-type inherit keeps the native
# Windows PATH (so node/yarn stay reachable) while MSYS2 bin is prepended,
# so `git` resolves to the MSYS build inside this shell.
- name: Setup MSYS2
uses: msys2/setup-msys2@e9898307ac31d1a803454791be09ab9973336e1c # v2.31.1
with:
msystem: MSYS
path-type: inherit
install: git

- name: Show toolchain (MSYS2 shell)
shell: msys2 {0}
run: |
echo "uname: $(uname -a)"
echo "git on PATH: $(command -v git)"
git --version
echo "node on PATH: $(command -v node)"
node -e "console.log('node platform:', process.platform, 'version:', process.version)"

# Create a real git repo on the C: drive and commit, then run the repro
# from the MSYS2 shell so getGitRepoRoot's internal `git rev-parse
# --show-toplevel` returns a `/c/...` MSYS path.
- name: Create git repo and run repro
shell: msys2 {0}
env:
MSYS_REPRO_MODE: ${{ github.event.inputs.mode || 'fixed' }}
run: |
set -euo pipefail
# MSYS path of the repo (a real directory on the C: drive).
REPO_DIR="/c/msys-repro-site"
rm -rf "$REPO_DIR"
mkdir -p "$REPO_DIR"
cd "$REPO_DIR"
git init -q
git config user.email repro@example.com
git config user.name Repro
echo "# repro" > README.md
git add README.md
git commit -q -m "initial commit"
echo "git rev-parse --show-toplevel => $(git rev-parse --show-toplevel)"

# The repro itself is native Windows Node, so it needs native Windows
# paths for both its script location and the repo cwd it passes to
# execa. The git that getGitRepoRoot shells out to is still the MSYS
# git on PATH, so it keeps returning the `/c/...` MSYS path.
WORKSPACE_UNIX="$(cygpath -u "${GITHUB_WORKSPACE}")"
REPO_DIR_WIN="$(cygpath -w "$REPO_DIR")"
SCRIPT_WIN="$(cygpath -w "${WORKSPACE_UNIX}/admin/scripts/msys-git-path-repro/repro.mjs")"
echo "repo dir (win) : ${REPO_DIR_WIN}"
echo "script (win) : ${SCRIPT_WIN}"
echo "running repro in mode: ${MSYS_REPRO_MODE}"
node "${SCRIPT_WIN}" "${REPO_DIR_WIN}"
208 changes: 208 additions & 0 deletions admin/scripts/msys-git-path-repro/repro.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
/**
* 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.
*/

/*
* Standalone CI reproduction for the MSYS/Cygwin Git path bug.
* See https://github.com/facebook/docusaurus/issues/11920
*
* Run this on a Windows runner from inside an MSYS2 / Git Bash shell, where
* `git` is the MSYS build of Git. In that environment,
* `git rev-parse --show-toplevel` prints an MSYS-style path such as
* `/c/Users/runner/work/.../repo` instead of the native `C:\...\repo`.
*
* It exercises the REAL `getGitRepoRoot` from the compiled package output and
* asserts the returned value is a valid native Windows path that exists and is
* not corrupted by a duplicated drive segment (the symptom of the bug).
*
* In the same run it also computes the OLD, unfixed behaviour,
* `fs.realpath.native(stdout)` on the raw MSYS path, and asserts that it is
* broken, so a single green run documents both the bug and the fix.
*
* Set MSYS_REPRO_MODE=nofix to make the assertion target the OLD behaviour
* instead, which produces a genuine failing run.
*/

import path from 'path';
import fs from 'fs';
import {execFileSync} from 'child_process';
import {createRequire} from 'module';
import {fileURLToPath} from 'url';
import {promisify} from 'util';

const require = createRequire(import.meta.url);
const here = path.dirname(fileURLToPath(import.meta.url));

// Async equivalents of the sync fs helpers (the repo lint bans sync fs methods).
const realpathNative = promisify(fs.realpath.native);
async function pathExists(p) {
try {
await fs.promises.access(p);
return true;
} catch {
return false;
}
}

// The compiled package output (lib), produced by `yarn build:packages`. We
// require the built module files directly because getGitRepoRoot is
// intentionally not part of the package's public index.
const libRoot = path.resolve(
here,
'..',
'..',
'..',
'packages',
'docusaurus-utils',
'lib',
);

const {getGitRepoRoot} = require(path.join(libRoot, 'vcs', 'gitUtils.js'));
const {fromGitPathToNativePath} = require(path.join(libRoot, 'pathUtils.js'));

const DUPLICATED_DRIVE_RE = /^[a-z]:[\\/][a-z](?:[\\/]|$)/i;
const MSYS_PATH_RE = /^\/[a-z](?:\/|$)/i;

async function main() {
const repoDir = process.argv[2];
if (!repoDir) {
throw new Error('Usage: node repro.mjs <repoDir>');
}

const mode = process.env.MSYS_REPRO_MODE === 'nofix' ? 'nofix' : 'fixed';

console.log('===== MSYS / Cygwin Git path reproduction =====');
console.log(`process.platform : ${process.platform}`);
console.log(`mode : ${mode}`);
console.log(`repo dir (cwd) : ${repoDir}`);

// 1. Show exactly what the MSYS git prints. This is the trigger condition.
const rawToplevel = execFileSync('git', ['rev-parse', '--show-toplevel'], {
cwd: repoDir,
encoding: 'utf8',
}).trim();
console.log(`git --show-toplevel : ${rawToplevel}`);

const looksLikeMsysPath = MSYS_PATH_RE.test(rawToplevel);
console.log(`looks like /c/... path : ${looksLikeMsysPath}`);
if (!looksLikeMsysPath) {
throw new Error(
`Expected git to return an MSYS-style /c/... path but got: ${rawToplevel}\n` +
'This run is not exercising the MSYS code path, so it proves nothing. ' +
'Make sure git is the MSYS2 / Git Bash build and the shell is msys2.',
);
}

// 2. Compute the OLD, unfixed behaviour: fs.realpath.native on the raw MSYS
// path. This is what the code did before the fix.
let unfixedResult = null;
let unfixedError = null;
try {
unfixedResult = await realpathNative(rawToplevel);
} catch (err) {
unfixedError = err.message;
}
console.log('----- unfixed behaviour: fs.realpath.native(rawStdout) -----');
console.log(` result : ${unfixedResult ?? '(threw)'}`);
console.log(` error : ${unfixedError ?? '(none)'}`);

const unfixedIsBroken =
unfixedError !== null ||
unfixedResult === null ||
DUPLICATED_DRIVE_RE.test(unfixedResult) ||
!(await pathExists(unfixedResult));
console.log(` unfixed is broken : ${unfixedIsBroken}`);

// 3. Show what the fix produces for the same input, in isolation.
const normalized = fromGitPathToNativePath(rawToplevel);
console.log('----- fix: fromGitPathToNativePath(rawStdout) -----');
console.log(` normalized : ${normalized}`);

// 4. Run the REAL getGitRepoRoot (the fixed integration point).
const repoRoot = await getGitRepoRoot(repoDir);
console.log('----- getGitRepoRoot(repoDir) -----');
console.log(` returned : ${repoRoot}`);

const repoRootIsNative = /^[a-z]:[\\/]/i.test(repoRoot);
const repoRootHasDuplicatedDrive = DUPLICATED_DRIVE_RE.test(repoRoot);
const repoRootExists = await pathExists(repoRoot);
console.log(` is native C:\\ path : ${repoRootIsNative}`);
console.log(` has duplicated drive : ${repoRootHasDuplicatedDrive}`);
console.log(` path exists on disk : ${repoRootExists}`);

const errors = [];

// The bug must actually reproduce, otherwise the run proves nothing.
if (!unfixedIsBroken) {
errors.push(
'The unfixed behaviour did NOT reproduce the bug. fs.realpath.native ' +
'on the raw MSYS path was expected to fail or produce a ' +
'duplicated-drive path that does not exist.',
);
}

if (mode === 'fixed') {
// Assert the fixed code is correct.
if (!repoRootIsNative) {
errors.push(
`getGitRepoRoot did not return a native Windows path: ${repoRoot}`,
);
}
if (repoRootHasDuplicatedDrive) {
errors.push(
`getGitRepoRoot returned a duplicated-drive path: ${repoRoot}`,
);
}
if (!repoRootExists) {
errors.push(
`getGitRepoRoot returned a path that does not exist: ${repoRoot}`,
);
}
} else {
// nofix mode: assert against the OLD behaviour so the run goes RED,
// producing a genuine failing CI run that documents the bug.
console.log(
'----- nofix mode: asserting the OLD fs.realpath.native(stdout) -----',
);
if (unfixedError !== null) {
errors.push(
`Unfixed code threw on the MSYS path (this is the bug): ${unfixedError}`,
);
} else {
if (DUPLICATED_DRIVE_RE.test(unfixedResult)) {
errors.push(
'Unfixed code produced a duplicated-drive path (this is the bug): ' +
unfixedResult,
);
}
if (!(await pathExists(unfixedResult))) {
errors.push(
'Unfixed code produced a path that does not exist (this is the bug): ' +
unfixedResult,
);
}
}
}

if (errors.length > 0) {
console.error('\n===== REPRO RESULT: FAIL =====');
for (const error of errors) {
console.error(` - ${error}`);
}
process.exit(1);
}

console.log('\n===== REPRO RESULT: PASS =====');
console.log(
'The MSYS path triggered the drive duplication in the unfixed code, and ' +
'getGitRepoRoot resolved it to a correct existing native path.',
);
}

main().catch((err) => {
console.error(err);
process.exit(1);
});
52 changes: 52 additions & 0 deletions packages/docusaurus-utils/src/__tests__/pathUtils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
toMessageRelativeFilePath,
addTrailingPathSeparator,
aliasedSitePathToRelativePath,
fromGitPathToNativePath,
} from '../pathUtils';

describe('isNameTooLong', () => {
Expand Down Expand Up @@ -208,3 +209,54 @@ describe('addTrailingPathSeparator', () => {
);
});
});

describe('fromGitPathToNativePath', () => {
function withPlatform<T>(platform: NodeJS.Platform, fn: () => T): T {
const oldProcessPlatform = process.platform;
Object.defineProperty(process, 'platform', {value: platform});
try {
return fn();
} finally {
Object.defineProperty(process, 'platform', {value: oldProcessPlatform});
}
}

it('converts MSYS/Cygwin drive paths to native Windows paths on Windows', () => {
withPlatform('win32', () => {
// See https://github.com/facebook/docusaurus/issues/11920
expect(fromGitPathToNativePath('/p/projets/my-repo')).toBe(
'P:\\projets\\my-repo',
);
expect(fromGitPathToNativePath('/c/Users/me/site')).toBe(
'C:\\Users\\me\\site',
);
expect(fromGitPathToNativePath('/c')).toBe('C:\\');
expect(fromGitPathToNativePath('/c/')).toBe('C:\\');
});
});

it('leaves native Windows, UNC and posix paths unchanged on Windows', () => {
withPlatform('win32', () => {
expect(fromGitPathToNativePath('C:\\Users\\me\\site')).toBe(
'C:\\Users\\me\\site',
);
expect(fromGitPathToNativePath('C:/Users/me/site')).toBe(
'C:/Users/me/site',
);
expect(fromGitPathToNativePath('//server/share')).toBe('//server/share');
// A real posix dir whose top-level segment is more than one character
// is not a drive mount and must not be rewritten.
expect(fromGitPathToNativePath('/home/me/site')).toBe('/home/me/site');
});
});

it('returns the path unchanged on non-Windows platforms', () => {
withPlatform('linux', () => {
// On Unix, /c/... is a perfectly valid absolute path and must be kept.
expect(fromGitPathToNativePath('/c/Users/me/site')).toBe(
'/c/Users/me/site',
);
expect(fromGitPathToNativePath('/home/me/site')).toBe('/home/me/site');
});
});
});
Loading