diff --git a/.github/workflows/msys-git-path-repro.yml b/.github/workflows/msys-git-path-repro.yml new file mode 100644 index 000000000000..b2d1970efbb9 --- /dev/null +++ b/.github/workflows/msys-git-path-repro.yml @@ -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}" diff --git a/admin/scripts/msys-git-path-repro/repro.mjs b/admin/scripts/msys-git-path-repro/repro.mjs new file mode 100644 index 000000000000..6840a69f9019 --- /dev/null +++ b/admin/scripts/msys-git-path-repro/repro.mjs @@ -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 '); + } + + 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); +}); diff --git a/packages/docusaurus-utils/src/__tests__/pathUtils.test.ts b/packages/docusaurus-utils/src/__tests__/pathUtils.test.ts index d368228c909f..3f332da76953 100644 --- a/packages/docusaurus-utils/src/__tests__/pathUtils.test.ts +++ b/packages/docusaurus-utils/src/__tests__/pathUtils.test.ts @@ -16,6 +16,7 @@ import { toMessageRelativeFilePath, addTrailingPathSeparator, aliasedSitePathToRelativePath, + fromGitPathToNativePath, } from '../pathUtils'; describe('isNameTooLong', () => { @@ -208,3 +209,54 @@ describe('addTrailingPathSeparator', () => { ); }); }); + +describe('fromGitPathToNativePath', () => { + function withPlatform(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'); + }); + }); +}); diff --git a/packages/docusaurus-utils/src/pathUtils.ts b/packages/docusaurus-utils/src/pathUtils.ts index 43ac9ba4c121..e04f0db8f0dc 100644 --- a/packages/docusaurus-utils/src/pathUtils.ts +++ b/packages/docusaurus-utils/src/pathUtils.ts @@ -64,6 +64,39 @@ export function posixPath(str: string): string { return str.replace(/\\/g, '/'); } +/** + * On Windows, Git can be run from a Unix-like shell (Git Bash / MSYS2 / Cygwin) + * that exposes drives as Unix-style mount points. In that environment, commands + * such as `git rev-parse --show-toplevel` may print an absolute path like + * `/c/Users/me/site` instead of the native `C:\Users\me\site`. + * + * Such a path is not a valid Windows path: passing it to `path.resolve` or + * `fs.realpath` resolves it against the current drive root, turning + * `/c/Users/me/site` into something like `C:\c\Users\me\site` (the drive letter + * gets duplicated). This converts the `//...` prefix back to a native + * Windows path so the rest of the code can use it safely. + * + * It only does anything on Windows, and only for paths that actually use the + * `//` mount syntax. Native Windows paths (`C:\...` or `C:/...`), UNC + * paths (`//server/share`) and regular posix paths are returned unchanged. On + * non-Windows platforms the path is always returned as-is, because `/c/...` is + * a perfectly valid absolute path there. + * + * See https://github.com/facebook/docusaurus/issues/11920 + */ +export function fromGitPathToNativePath(gitPath: string): string { + if (!isWindows()) { + return gitPath; + } + const match = gitPath.match(/^\/(?[a-z])(?:\/(?.*))?$/i); + if (!match) { + return gitPath; + } + const drive = match.groups!.drive!.toUpperCase(); + const rest = match.groups!.rest ?? ''; + return path.win32.join(`${drive}:\\`, rest); +} + /** * When you want to display a path in a message/warning/error, it's more * convenient to: diff --git a/packages/docusaurus-utils/src/vcs/gitUtils.ts b/packages/docusaurus-utils/src/vcs/gitUtils.ts index a1c13997401a..cc183da7fde5 100644 --- a/packages/docusaurus-utils/src/vcs/gitUtils.ts +++ b/packages/docusaurus-utils/src/vcs/gitUtils.ts @@ -12,6 +12,7 @@ import _ from 'lodash'; import execa from 'execa'; import PQueue from 'p-queue'; import logger from '@docusaurus/logger'; +import {fromGitPathToNativePath} from '../pathUtils'; // Quite high/conservative concurrency value (it was previously "Infinity") // See https://github.com/facebook/docusaurus/pull/10915 @@ -307,7 +308,7 @@ The command returned exit code ${logger.code(result.exitCode)}: ${logger.subdue( ); } - return fs.realpath.native(result.stdout.trim()); + return fs.realpath.native(fromGitPathToNativePath(result.stdout.trim())); } // A Git "superproject" is a Git repository that contains submodules @@ -351,7 +352,7 @@ The command returned exit code ${logger.code(result.exitCode)}: ${logger.subdue( // 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.native(output); + return fs.realpath.native(fromGitPathToNativePath(output)); } return getGitRepoRoot(cwd); }